feat(scheduling): use central people picker

This commit is contained in:
2026-07-22 03:17:13 +02:00
parent 2cb86c90dc
commit ea2f721377
13 changed files with 529 additions and 231 deletions

View File

@@ -15,7 +15,7 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-workspace-layout"/);
@@ -63,20 +63,21 @@ for (const setting of [
assert.match(page, /<PasswordField[\s\S]*minLength=\{8\}/);
assert.match(page, /type="number"[\s\S]*min=\{1\}/);
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 1\)\}/);
assert.match(page, /create_participant_invitations: true/);
assert.match(page, /allow_external_participants: allowExternalParticipants/);
assert.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
assert.match(page, /public_participation_policy_enforcement_available/);
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
assert.match(page, /id="scheduling-create-participants-grid"/);
assert.match(page, /id="scheduling-participant-picker"/);
assert.match(page, /id="scheduling-candidate-slots-grid"/);
assert.match(page, /id="scheduling-participants-grid"/);
assert.match(page, /<DataGridRowActions/);
assert.match(page, /<DataGridEmptyAction/);
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
assert.doesNotMatch(page, /<input[\s\S]{0,220}aria-label=\{I18N\.participantEmail\}/);
assert.match(page, /allowManualExternal=\{allowExternalParticipants\}/);
assert.match(page, /search=\{participantSearch\}/);
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
@@ -108,6 +109,8 @@ for (const field of [
assert.match(api, new RegExp(`${field}:`));
}
assert.match(api, /method: "PATCH"/);
assert.match(api, /\/api\/v1\/scheduling\/people\?/);
assert.doesNotMatch(api, /address-lookup/);
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);

View File

@@ -1,4 +1,8 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import {
apiFetch,
type ApiSettings,
type PeoplePickerSearchGroup
} from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
@@ -299,31 +303,25 @@ export type SchedulingSummaryResponse = {
};
};
export type SchedulingAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type SchedulingAddressLookupResponse = {
available: boolean;
candidates: SchedulingAddressLookupCandidate[];
export type SchedulingPeopleSearchResponse = {
groups: PeoplePickerSearchGroup[];
};
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
export async function searchSchedulingPeople(
settings: ApiSettings,
query: string,
limit = 25,
signal?: AbortSignal
): Promise<PeoplePickerSearchGroup[]> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
const response = await apiFetch<SchedulingPeopleSearchResponse>(
settings,
`/api/v1/scheduling/people?${params.toString()}`,
{ signal }
);
return response.groups;
}
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
Bell,
@@ -16,7 +16,6 @@ import {
Button,
Card,
DataGrid,
DataGridEmptyAction,
DataGridRowActions,
DateTimeField,
DismissibleAlert,
@@ -25,6 +24,7 @@ import {
IconButton,
PageTitle,
PasswordField,
PeoplePicker,
formatDateTime,
SelectionList,
SelectionListItem,
@@ -41,7 +41,9 @@ import {
type AuthInfo,
type CalendarPickerUiCapability,
type DataGridColumn,
type FormatDateTimeOptions
type FormatDateTimeOptions,
type PeoplePickerItem,
type PeoplePickerSearch
} from "@govoplan/core-webui";
import {
closeSchedulingRequest,
@@ -55,6 +57,7 @@ import {
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
searchSchedulingPeople,
schedulingSummary,
submitSchedulingAvailability,
updateSchedulingRequest,
@@ -70,10 +73,14 @@ import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
schedulingParticipantForActor,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingParticipantDraft,
type SchedulingRequestGroups
} from "./schedulingViewModel";
@@ -89,17 +96,7 @@ type SlotDraft = {
timezone: string;
metadata?: Record<string, unknown>;
};
type ParticipantDraft = {
draftId: string;
sourceId?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
};
type ParticipantDraft = SchedulingParticipantDraft;
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -174,6 +171,9 @@ const I18N = {
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",
@@ -235,7 +235,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [calendarId, setCalendarId] = useState("");
const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
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);
@@ -323,6 +324,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
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();
@@ -482,17 +487,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
timezone: slot.timezone,
metadata: slot.metadata
})));
setParticipants(request.participants.map((participant) => ({
draftId: nextDraftId("participant"),
sourceId: participant.id,
respondent_id: participant.respondent_id,
display_name: participant.display_name ?? "",
email: participant.email ?? "",
participant_type: normalizeParticipantType(participant.participant_type),
required: participant.required ?? true,
metadata: participant.metadata,
identityLocked: Boolean(participant.poll_invitation_id)
})));
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);
@@ -555,6 +553,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
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,
@@ -587,16 +586,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
required: participant.required,
participant_type: participant.participant_type,
metadata: participant.metadata ?? {}
})),
participants: participants.map(participantPayload),
create_participant_invitations: true
});
setRequests((items) => [request, ...items]);
@@ -617,17 +607,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.sourceId || participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
...(participant.sourceId ? { id: participant.sourceId } : {}),
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
participant_type: participant.participant_type,
required: participant.required,
metadata: participant.metadata ?? {}
})),
participants: participants.map(participantPayload),
create_participant_invitations: true
});
replaceRequest(request);
@@ -851,16 +831,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<EditableParticipants
participants={participants}
disabled={!canCreateOrWrite}
onAddBelow={(index) => {
setParticipants((items) => insertAfter(items, index, emptyParticipant()));
allowExternalParticipants={allowExternalParticipants}
search={participantSearch}
onAllowExternalParticipantsChange={(value) => {
setAllowExternalParticipants(value);
setDraftDirty(true);
}}
onChange={(index, patch) => {
setParticipants((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
setDraftDirty(true);
}}
onRemove={(index) => {
setParticipants((items) => items.filter((_item, itemIndex) => itemIndex !== index));
onChange={(value) => {
setParticipants((current) => participantDraftsFromPicker(
value,
current,
() => nextDraftId("participant")
));
setDraftDirty(true);
}} />
@@ -1174,7 +1156,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
setCalendarId("");
setCalendarEnabled(false);
setSlots(initialSlots(translateText));
setParticipants([emptyParticipant()]);
setParticipants([]);
setAllowExternalParticipants(true);
setParticipantRosterVisible(false);
setNotifyOnAnswers(true);
setSingleChoice(false);
@@ -1518,82 +1501,40 @@ function EditableSlots({
function EditableParticipants({
participants,
disabled,
onAddBelow,
onChange,
onRemove
allowExternalParticipants,
search,
onAllowExternalParticipantsChange,
onChange
}: {
participants: ParticipantDraft[];
disabled: boolean;
onAddBelow: (index: number) => void;
onChange: (index: number, patch: Partial<ParticipantDraft>) => void;
onRemove: (index: number) => void;
allowExternalParticipants: boolean;
search: PeoplePickerSearch;
onAllowExternalParticipantsChange: (value: boolean) => void;
onChange: (participants: PeoplePickerItem[]) => void;
}) {
const lockedIdentityHelpId = "scheduling-invited-participant-identity-help";
const columns: DataGridColumn<ParticipantDraft>[] = [
{
id: "name",
header: I18N.name,
minWidth: 180,
resizable: true,
value: (participant) => participant.display_name,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
maxLength={500}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.name}
value={participant.display_name}
onChange={(event) => onChange(index, { display_name: event.target.value })} />
)
},
{
id: "email",
header: I18N.participantEmail,
minWidth: 260,
resizable: true,
value: (participant) => participant.email,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
type="email"
maxLength={320}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.participantEmail}
value={participant.email}
onChange={(event) => onChange(index, { email: event.target.value })} />
)
},
{
id: "actions",
header: I18N.actions,
width: 112,
sticky: "end",
render: (_participant, index) => (
<DataGridRowActions
disabled={disabled}
reorderable={false}
onAddBelow={() => onAddBelow(index)}
onRemove={() => onRemove(index)}
addLabel={I18N.addParticipant}
removeLabel={i18nMessage("i18n:govoplan-scheduling.remove_participant_value.e55f2b70", { value0: index + 1 })} />
)
}
];
return (
<Card title={I18N.participants}>
{participants.some((participant) => participant.identityLocked) ? (
<p id={lockedIdentityHelpId} className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
<p className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
) : null}
<DataGrid
id="scheduling-create-participants-grid"
rows={participants}
columns={columns}
getRowKey={(participant) => participant.draftId}
initialFit="container"
emptyText={I18N.noParticipants}
emptyActionColumnId="actions"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={() => onAddBelow(-1)} label={I18N.addParticipant} />} />
<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>
);
}
@@ -1792,21 +1733,6 @@ function newSlot(position: number, translateText: (value: string) => string): Sl
};
}
function emptyParticipant(): ParticipantDraft {
return {
draftId: nextDraftId("participant"),
display_name: "",
email: "",
participant_type: "external",
required: true,
metadata: {}
};
}
function normalizeParticipantType(value: string | null): ParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
let draftIdSequence = 0;
function nextDraftId(prefix: string): string {

View File

@@ -1,8 +1,24 @@
import type {
SchedulingAvailabilityValue,
SchedulingParticipant,
SchedulingParticipantPayload,
SchedulingRequest
} from "../../api/scheduling";
import type { PeoplePickerItem } from "@govoplan/core-webui";
const DIRECTORY_SELECTION_METADATA_KEY = "directory_selection";
export type SchedulingParticipantDraft = PeoplePickerItem & {
draftId: string;
sourceId?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
};
export type SchedulingActor = {
accountId?: string | null;
@@ -25,6 +41,106 @@ export type SchedulingSortPhase =
| "determined"
| "past";
type DirectorySelection = {
selection_key?: string;
kind?: PeoplePickerItem["kind"];
reference_id?: string | null;
source_module?: string | null;
source_label?: string | null;
source_revision?: string | null;
};
function directorySelection(metadata?: Record<string, unknown>): DirectorySelection | null {
const value = metadata?.[DIRECTORY_SELECTION_METADATA_KEY];
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as DirectorySelection;
}
function normalizedParticipantType(value: string | null): SchedulingParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
function selectionMetadata(item: PeoplePickerItem): Record<string, unknown> {
if (item.kind === "external") return {};
return {
[DIRECTORY_SELECTION_METADATA_KEY]: {
selection_key: item.selection_key,
kind: item.kind,
reference_id: item.reference_id ?? null,
source_module: item.source_module ?? null,
source_label: item.source_label ?? null,
source_revision: item.source_revision ?? null
}
};
}
export function participantDraftFromResponse(
participant: SchedulingParticipant,
draftId: string
): SchedulingParticipantDraft {
const selection = directorySelection(participant.metadata);
const kind = selection?.kind ?? (participant.respondent_id ? "account" : "external");
const referenceId = selection?.reference_id
?? (kind === "account" ? participant.respondent_id : null);
const email = participant.email?.trim().toLowerCase() || null;
return {
selection_key: selection?.selection_key
?? (email ? `${kind}:${email}` : `${kind}:participant:${participant.id}`),
kind,
reference_id: referenceId,
display_name: participant.display_name?.trim() || email || "—",
email: email ?? "",
source_module: selection?.source_module ?? null,
source_label: selection?.source_label ?? null,
source_revision: selection?.source_revision ?? null,
draftId,
sourceId: participant.id,
respondent_id: participant.respondent_id,
participant_type: normalizedParticipantType(participant.participant_type),
required: participant.required ?? true,
metadata: participant.metadata ?? {},
identityLocked: Boolean(participant.poll_invitation_id)
};
}
export function participantDraftsFromPicker(
selected: PeoplePickerItem[],
current: SchedulingParticipantDraft[],
nextDraftId: () => string
): SchedulingParticipantDraft[] {
const currentByKey = new Map(current.map((participant) => [participant.selection_key, participant]));
return selected.map((item) => {
const existing = currentByKey.get(item.selection_key);
if (existing) return existing;
const kind = item.kind === "account" ? "account" : item.kind === "contact" ? "contact" : "external";
return {
...item,
kind,
email: item.email?.trim().toLowerCase() || "",
draftId: nextDraftId(),
respondent_id: kind === "account" ? item.reference_id ?? null : null,
participant_type: kind === "account" ? "internal" : "external",
required: true,
metadata: selectionMetadata(item),
identityLocked: false
};
});
}
export function participantPayload(
participant: SchedulingParticipantDraft
): SchedulingParticipantPayload & { id?: string } {
return {
...(participant.sourceId ? { id: participant.sourceId } : {}),
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
participant_type: participant.participant_type,
required: participant.required,
metadata: participant.metadata ?? {}
};
}
export function schedulingActorIds(actor: SchedulingActor): string[] {
return Array.from(new Set([
actor.accountId,

View File

@@ -13,6 +13,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Allow external participants",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
"i18n:govoplan-scheduling.comment.d03495b1": "Comment",
@@ -35,6 +36,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.",
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Search accounts and contacts you are allowed to discover. If external participants are enabled, you can also add a name and email address manually.",
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "When enabled, people outside the configured accounts and contacts can be added manually.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses",
@@ -162,6 +165,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Externe Teilnehmende erlauben",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar",
@@ -184,6 +188,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Suchen Sie nach Konten und Kontakten, die Sie sehen dürfen. Wenn externe Teilnehmende erlaubt sind, können Sie Name und E-Mail-Adresse auch manuell hinzufügen.",
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "Wenn diese Option aktiviert ist, können Personen außerhalb der eingerichteten Konten und Kontakte manuell hinzugefügt werden.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",

View File

@@ -13,7 +13,7 @@ export const schedulingModule: PlatformWebModule = {
label: "Scheduling",
version: "0.1.10",
dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [

View File

@@ -4,10 +4,99 @@ import type { SchedulingRequest } from "../src/api/scheduling.ts";
import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingSortPhase,
type SchedulingActor
} from "../src/features/scheduling/schedulingViewModel.ts";
test("maps visible account and contact selections into bounded scheduling participant payloads", () => {
let sequence = 0;
const selected = participantDraftsFromPicker([
{
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
display_name: "Ada Account",
email: "ADA@EXAMPLE.TEST",
source_module: "access",
source_label: "Accounts",
provenance: { tenant_id: "must-not-be-persisted" },
metadata: { group_ids: ["must-not-be-persisted"] }
},
{
selection_key: "contact:contact-3:contact@example.test",
kind: "contact",
reference_id: "contact-3",
display_name: "Contact Person",
email: "contact@example.test",
source_module: "addresses",
source_label: "Contacts",
source_revision: "revision-3",
provenance: { address_book_id: "must-not-be-persisted" }
}
], [], () => `participant-${++sequence}`);
assert.equal(selected[0].respondent_id, "account-2");
assert.equal(selected[0].participant_type, "internal");
assert.equal(selected[0].email, "ada@example.test");
assert.deepEqual(selected[0].metadata, {
directory_selection: {
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
source_module: "access",
source_label: "Accounts",
source_revision: null
}
});
assert.equal(selected[1].respondent_id, null);
assert.equal(selected[1].participant_type, "external");
assert.equal(
(selected[1].metadata?.directory_selection as { source_revision: string }).source_revision,
"revision-3"
);
assert.deepEqual(participantPayload(selected[1]), {
respondent_id: null,
display_name: "Contact Person",
email: "contact@example.test",
participant_type: "external",
required: true,
metadata: selected[1].metadata
});
});
test("reconstructs saved directory selections and preserves existing reconciliation identity", () => {
const responseParticipant = {
id: "stored-participant",
is_current_participant: false,
respondent_id: "account-2",
display_name: "Ada Account",
email: "ada@example.test",
participant_type: "internal",
required: true,
status: "invited",
poll_invitation_id: "invitation-1",
metadata: {
directory_selection: {
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
source_module: "access",
source_label: "Accounts"
}
}
};
const draft = participantDraftFromResponse(responseParticipant, "draft-1");
const remapped = participantDraftsFromPicker([draft], [draft], () => "unexpected");
assert.equal(remapped[0], draft);
assert.equal(draft.sourceId, "stored-participant");
assert.equal(draft.identityLocked, true);
assert.equal(participantPayload(draft).id, "stored-participant");
});
const now = new Date("2026-07-20T10:00:00Z");
const actor: SchedulingActor = {
accountId: "account-1",