feat: reconcile tentative calendar holds

This commit is contained in:
2026-08-20 07:44:15 +02:00
parent fb8d674637
commit 79cfaa951a
7 changed files with 522 additions and 29 deletions
@@ -27,7 +27,6 @@ import { ContentGrid, FormGrid,
DocumentationHelpLink,
FormField,
MetricCard,
IconButton,
PageTitle,
PasswordField,
PeoplePicker,
@@ -127,6 +126,11 @@ type DecisionTarget = {
requestId: string;
slot: SchedulingCandidateSlot;
};
type SchedulingCalendarCleanup = {
status: "accepted" | "retry_required";
targetState: string | null;
pendingCount: number;
};
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -148,6 +152,8 @@ const I18N = {
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",
calendarCleanupRetryTitle: "i18n:govoplan-scheduling.calendar_cleanup_retry_title",
calendarCleanupRetryMessage: "i18n:govoplan-scheduling.calendar_cleanup_retry_message",
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",
@@ -342,6 +348,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
[canAdminister, groups, requests, selectedId]
);
const calendarCleanup = selected ? schedulingCalendarCleanup(selected) : null;
const selectedParticipant = useMemo(
() => selected ? schedulingParticipantForActor(selected, actor) : null,
[actor, selected]
@@ -368,7 +375,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
selectedPhase !== "past" &&
selected.status === "decided" &&
selected.selected_slot_id &&
!selected.calendar_event_id &&
(!selected.calendar_event_id || (
calendarCleanup?.status === "retry_required"
&& calendarCleanup.targetState === "handed_off"
)) &&
selected.create_calendar_event_on_decision
);
const optionResultById = useMemo(() => {
@@ -1298,7 +1308,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card>
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
<Card
title={I18N.calendarCoordination}
actions={(
@@ -1342,6 +1352,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.configuredCalendar}
disabled />
) : <p>{I18N.configuredCalendar}</p>}
{calendarCleanup?.status === "retry_required" ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
<strong>{I18N.calendarCleanupRetryTitle}</strong>{" "}
{i18nMessage(I18N.calendarCleanupRetryMessage, {
value0: calendarCleanup.pendingCount
})}
</DismissibleAlert>
) : null}
</Card>
) : null}
@@ -1714,6 +1732,20 @@ function ParticipationStats({
);
}
function schedulingCalendarCleanup(request: SchedulingRequest): SchedulingCalendarCleanup | null {
const value = request.metadata?.calendar_cleanup;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const status = record.status === "retry_required" ? "retry_required" : record.status === "accepted" ? "accepted" : null;
if (!status) return null;
const pending = Array.isArray(record.pending_slot_ids) ? record.pending_slot_ids : [];
return {
status,
targetState: typeof record.target_state === "string" ? record.target_state : null,
pendingCount: pending.length
};
}
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
title: string;
message: string;
+4
View File
@@ -1,5 +1,7 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
@@ -180,6 +182,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",