Apply guided scheduling interface patterns

This commit is contained in:
2026-08-03 08:29:29 +02:00
parent 3e9a14a970
commit 3d8272b28e
7 changed files with 225 additions and 4 deletions
@@ -15,6 +15,7 @@ import {
XCircle
} from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
@@ -22,6 +23,7 @@ import {
DataGridRowActions,
DateTimeField,
DismissibleAlert,
DocumentationHelpLink,
FormField,
MetricCard,
IconButton,
@@ -34,6 +36,7 @@ import {
TableActionGroup,
ToggleSwitch,
StatusBadge,
StageRail,
hasScope,
i18nMessage,
isApiError,
@@ -84,12 +87,14 @@ import {
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingLifecycleStages,
schedulingPublicInvitationUrl,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingInvitationActionBlock,
type SchedulingLifecycleStageState,
type SchedulingParticipantDraft,
type SchedulingRequestGroups
} from "./schedulingViewModel";
@@ -130,6 +135,7 @@ const I18N = {
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",
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
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",
@@ -142,6 +148,7 @@ const I18N = {
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",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
discard: "i18n:govoplan-scheduling.discard.36fff63c",
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
@@ -215,6 +222,7 @@ const I18N = {
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",
publicPolicyRequiredAction: "i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105",
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",
@@ -236,6 +244,11 @@ const I18N = {
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
title: "i18n:govoplan-scheduling.title.768e0c1c",
titleRequired: "i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be",
requiredAction: "i18n:govoplan-scheduling.required_action.f1a20101",
responsibleActor: "i18n:govoplan-scheduling.who_can_fix_it.f1a20102",
resolutionTarget: "i18n:govoplan-scheduling.where_to_go.f1a20103",
systemOrTenantAdministrator: "i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104",
administration: "i18n:govoplan-core.admin.4e7afebc",
unchecked: "i18n:govoplan-scheduling.unchecked.1b927dec",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79",
updateResponse: "i18n:govoplan-scheduling.update_response.346233cf",
@@ -935,7 +948,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
if (!checked) setCalendarId("");
setDraftDirty(true);
}} />
{!calendarIntegrationAvailable ? <p className="scheduling-capability-note">{I18N.calendarUnavailable}</p> : null}
{!calendarIntegrationAvailable ? (
<ActionBlockerHint
tone="info"
reason={{
summary: I18N.calendarUnavailable,
requiredAction: I18N.calendarRequiredAction,
actor: I18N.systemOrTenantAdministrator,
target: I18N.administration
}}
labels={{
requiredAction: I18N.requiredAction,
actor: I18N.responsibleActor,
target: I18N.resolutionTarget
}}
documentation={{
topicId: "scheduling.calendar-coordination",
documentationType: "admin"
}} />
) : null}
{calendarEnabled && CalendarPicker ? (
<CalendarPicker
settings={settings}
@@ -1030,10 +1061,36 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</div>
) : selected ? (
<div className="scheduling-detail">
<StageRail
className="scheduling-lifecycle-rail"
ariaLabel={I18N.requests}
items={schedulingLifecycleStages(selected.status).map((stage) => ({
id: stage.id,
label: stage.id === "prepare"
? I18N.basicInformation
: stage.id === "participate"
? I18N.participation
: I18N.decision,
icon: stage.id === "prepare"
? <Pencil aria-hidden="true" size={15} />
: stage.id === "participate"
? <Send aria-hidden="true" size={15} />
: <Check aria-hidden="true" size={15} />,
tone: lifecycleStageTone(stage.state),
current: stage.current,
locked: stage.locked,
lockedLabel: I18N.unavailable,
statusLabel: stage.current ? requestStatusLabel(selected, actor) : undefined
}))} />
<Card
title={selected.title}
actions={(
<div className="scheduling-actions">
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
{canEditSelected ? (
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
@@ -1070,9 +1127,23 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
) : null}
{selected.public_participation_policy_enforcement_available === false ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
{selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable}
</DismissibleAlert>
<ActionBlockerHint
reason={{
summary: selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable,
details: I18N.publicPolicyUnavailable,
requiredAction: I18N.publicPolicyRequiredAction,
actor: I18N.systemOrTenantAdministrator,
target: I18N.administration
}}
labels={{
requiredAction: I18N.requiredAction,
actor: I18N.responsibleActor,
target: I18N.resolutionTarget
}}
documentation={{
topicId: "scheduling.participation-governance",
documentationType: "admin"
}} />
) : null}
{selectedParticipant && selected.status === "collecting" ? (
@@ -1957,6 +2028,15 @@ function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean):
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
}
function lifecycleStageTone(
state: SchedulingLifecycleStageState
): "success" | "active" | "warning" | "neutral" {
if (state === "complete") return "success";
if (state === "current") return "active";
if (state === "stopped") return "warning";
return "neutral";
}
function requestStatusLabel(request: SchedulingRequest, actor: SchedulingActor): string {
const phase = schedulingSortPhase(request, actor);
if (phase === "past") return I18N.past;
@@ -35,6 +35,21 @@ export type SchedulingRequestGroups = {
other: SchedulingRequest[];
};
export type SchedulingLifecycleStageId = "prepare" | "participate" | "decide";
export type SchedulingLifecycleStageState =
| "complete"
| "current"
| "locked"
| "stopped";
export type SchedulingLifecycleStage = {
id: SchedulingLifecycleStageId;
state: SchedulingLifecycleStageState;
current: boolean;
locked: boolean;
};
export type SchedulingSortPhase =
| "unanswered"
| "answered"
@@ -282,6 +297,37 @@ export function schedulingRequestIsPast(
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
}
export function schedulingLifecycleStages(
status: SchedulingRequest["status"]
): SchedulingLifecycleStage[] {
const currentIndex = status === "draft"
? 0
: status === "collecting" || status === "cancelled"
? 1
: 2;
const completedThrough = status === "draft"
? -1
: status === "collecting" || status === "cancelled"
? 0
: status === "closed"
? 1
: 2;
const stopped = status === "cancelled";
return (["prepare", "participate", "decide"] as const).map((id, index) => {
const current = index === currentIndex;
const locked = index > completedThrough + 1;
const state: SchedulingLifecycleStageState = stopped && current
? "stopped"
: index <= completedThrough
? "complete"
: current
? "current"
: "locked";
return { id, state, current, locked };
});
}
export function schedulingInvitationActionBlocks(
request: SchedulingRequest,
participant: SchedulingParticipant,
+12
View File
@@ -78,6 +78,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.",
"i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106": "Enable Calendar and grant calendar, availability, and event access.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy",
@@ -97,6 +98,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.discard.36fff63c": "Discard",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
"i18n:govoplan-scheduling.draft.23d33e22": "Draft",
"i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105": "Enable enforceable public participation controls, or keep participation signed in.",
"i18n:govoplan-scheduling.end.a2bb9d34": "End",
"i18n:govoplan-scheduling.error.7f2f6a15": "Error",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
@@ -133,6 +135,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
"i18n:govoplan-scheduling.queued.6a599877": "Queued",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests",
"i18n:govoplan-scheduling.required_action.f1a20101": "Required action",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.",
"i18n:govoplan-scheduling.remove.e963907d": "Remove",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
@@ -157,6 +160,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"i18n:govoplan-scheduling.start.952f3754": "Start",
"i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104": "System or tenant administrator",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
@@ -170,6 +174,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?",
"i18n:govoplan-scheduling.where_to_go.f1a20103": "Where to go",
"i18n:govoplan-scheduling.who_can_fix_it.f1a20102": "Who can fix it",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
@@ -252,6 +258,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration",
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.",
"i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106": "Aktivieren Sie Kalender und vergeben Sie Rechte für Kalender, Verfügbarkeiten und Termine.",
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen",
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge",
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
@@ -271,6 +278,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
"i18n:govoplan-scheduling.draft.23d33e22": "Entwurf",
"i18n:govoplan-scheduling.enable_enforceable_public_participation_controls_or_keep_participation_signed_in.f1a20105": "Aktivieren Sie durchsetzbare Regeln für die öffentliche Teilnahme oder beschränken Sie die Teilnahme auf angemeldete Personen.",
"i18n:govoplan-scheduling.end.a2bb9d34": "Ende",
"i18n:govoplan-scheduling.error.7f2f6a15": "Fehler",
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
@@ -307,6 +315,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren",
"i18n:govoplan-scheduling.required_action.f1a20101": "Erforderliche Maßnahme",
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.",
"i18n:govoplan-scheduling.remove.e963907d": "Entfernen",
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
@@ -331,6 +340,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-scheduling.start.952f3754": "Beginn",
"i18n:govoplan-scheduling.system_or_tenant_administrator.f1a20104": "System- oder Mandantenadministration",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
@@ -344,6 +354,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?",
"i18n:govoplan-scheduling.where_to_go.f1a20103": "Ziel",
"i18n:govoplan-scheduling.who_can_fix_it.f1a20102": "Zuständig",
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.",
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
}