intermittent commit

This commit is contained in:
2026-07-14 13:22:12 +02:00
parent c1afce7bdb
commit 2f1b7fb6b8
9 changed files with 364 additions and 9 deletions

View File

@@ -114,9 +114,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
} }
def _scheduling_router(_context: ModuleContext): def _scheduling_router(context: ModuleContext):
from govoplan_scheduling.backend.runtime import configure_runtime
from govoplan_scheduling.backend.router import router from govoplan_scheduling.backend.router import router
configure_runtime(registry=context.registry, settings=context.settings)
return router return router
@@ -125,7 +127,7 @@ manifest = ModuleManifest(
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("poll",), dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations"), optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"), ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
@@ -136,14 +138,16 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
frontend=FrontendModule( frontend=FrontendModule(
module_id=MODULE_ID, module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui", package_name="@govoplan/scheduling-webui",
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
), ),
route_factory=_scheduling_router, route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,6 +11,8 @@ from govoplan_core.db.session import get_session
from govoplan_poll.backend.schemas import PollResultSummaryResponse from govoplan_poll.backend.schemas import PollResultSummaryResponse
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingCalendarActionResponse, SchedulingCalendarActionResponse,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingNotificationCreateRequest, SchedulingNotificationCreateRequest,
@@ -20,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingStatusResponse, SchedulingStatusResponse,
SchedulingSummaryResponse, SchedulingSummaryResponse,
) )
from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import ( from govoplan_scheduling.backend.service import (
SchedulingError, SchedulingError,
cancel_scheduling_request, cancel_scheduling_request,
@@ -42,6 +48,39 @@ from govoplan_scheduling.backend.service import (
router = APIRouter(prefix="/scheduling", tags=["scheduling"]) router = APIRouter(prefix="/scheduling", tags=["scheduling"])
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)
if isinstance(value, dict):
return dict(value)
payload: dict[str, Any] = {}
for key in (
"contact_id",
"address_book_id",
"display_name",
"email",
"email_label",
"organization",
"role_title",
"tags",
"source_kind",
"source_ref",
"source_revision",
"provenance",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
return payload
def _registry_capability(name: str) -> object | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
return None
return registry.capability(name)
def _require_scope(principal: ApiPrincipal, scope: str) -> None: def _require_scope(principal: ApiPrincipal, scope: str) -> None:
@@ -61,6 +100,24 @@ def _request_response(request, *, invitation_tokens: dict[str, str] | None = Non
) )
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
def api_lookup_scheduling_addresses(
query: str = Query(min_length=1),
limit: int = Query(default=25, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAddressLookupResponse:
_require_scope(principal, WRITE_SCOPE)
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return SchedulingAddressLookupResponse(available=False, candidates=[])
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
return SchedulingAddressLookupResponse(
available=True,
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
)
@router.get("/requests", response_model=SchedulingRequestListResponse) @router.get("/requests", response_model=SchedulingRequestListResponse)
def api_list_scheduling_requests( def api_list_scheduling_requests(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
_runtime_registry: object | None = None
_runtime_settings: object | None = None
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object | None:
return _runtime_settings

View File

@@ -202,3 +202,23 @@ class SchedulingNotificationCreateRequest(BaseModel):
event_kind: Literal["invitation", "reminder", "decision", "cancellation"] event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
channel: str = Field(default="mail", max_length=40) channel: str = Field(default="mail", max_length=40)
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class SchedulingAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)

View File

@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
from govoplan_poll.backend.db.models import PollResponse from govoplan_poll.backend.db.models import PollResponse
@@ -24,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestCreateRequest, SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest, SchedulingRequestUpdateRequest,
) )
from govoplan_scheduling.backend.runtime import get_registry
class SchedulingError(ValueError): class SchedulingError(ValueError):
@@ -348,6 +350,7 @@ def create_scheduling_notification_jobs(
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}") raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
notifications: list[SchedulingNotification] = [] notifications: list[SchedulingNotification] = []
base_metadata = metadata or {}
for participant in _active_participants(request): for participant in _active_participants(request):
recipient = participant.email or participant.respondent_id recipient = participant.email or participant.respondent_id
status = "pending" if recipient else "skipped" status = "pending" if recipient else "skipped"
@@ -367,16 +370,178 @@ def create_scheduling_notification_jobs(
"selected_slot_id": request.selected_slot_id, "selected_slot_id": request.selected_slot_id,
"calendar_event_id": request.calendar_event_id, "calendar_event_id": request.calendar_event_id,
}, },
metadata_=metadata or {}, metadata_=dict(base_metadata),
) )
if status == "skipped": if status == "skipped":
notification.error = "Participant has no deliverable recipient address or id" notification.error = "Participant has no deliverable recipient address or id"
session.add(notification) session.add(notification)
notifications.append(notification) notifications.append(notification)
session.flush() session.flush()
_mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications)
return notifications return notifications
def _mirror_scheduling_notifications_to_center(
session: Session,
*,
request: SchedulingRequest,
notifications: list[SchedulingNotification],
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
participants_by_id = {participant.id: participant for participant in _active_participants(request)}
for notification in notifications:
if notification.status == "skipped":
continue
participant = participants_by_id.get(notification.participant_id or "")
try:
response = provider.enqueue_notification(
session,
_notification_dispatch_request(request=request, participant=participant, notification=notification),
enqueue_delivery=True,
)
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
notification.status = "failed"
notification.error = f"Notification center enqueue failed: {exc}"
continue
notification.metadata_ = {
**(notification.metadata_ or {}),
"notification_id": response.get("id"),
"notification_status": response.get("status"),
}
if response.get("status") in {"queued", "sending", "sent"}:
notification.status = str(response["status"])
elif response.get("status") == "skipped":
notification.status = "skipped"
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
session.flush()
def _notification_dispatch_request(
*,
request: SchedulingRequest,
participant: SchedulingParticipant | None,
notification: SchedulingNotification,
) -> NotificationDispatchRequest:
payload = {
**(notification.payload or {}),
"scheduling_notification_id": notification.id,
"participant": {
"id": participant.id,
"display_name": participant.display_name,
"email": participant.email,
"required": participant.required,
"participant_type": participant.participant_type,
}
if participant
else None,
}
return NotificationDispatchRequest(
tenant_id=request.tenant_id,
source_module="scheduling",
source_resource_type="request",
source_resource_id=request.id,
event_kind=notification.event_kind,
channel=notification.channel,
recipient=notification.recipient,
recipient_type="scheduling_participant",
recipient_id=participant.id if participant else notification.participant_id,
recipient_label=participant.display_name if participant else None,
subject=_notification_subject(request, notification.event_kind),
body_text=_notification_body_text(request, participant, notification.event_kind),
action_url=f"/scheduling?request_id={request.id}",
payload=_jsonable(payload),
metadata={
**(notification.metadata_ or {}),
"scheduling_notification_id": notification.id,
"scheduling_request_id": request.id,
},
)
def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
labels = {
"invitation": "Scheduling invitation",
"reminder": "Scheduling reminder",
"decision": "Scheduling decision",
"cancellation": "Scheduling cancelled",
}
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
def _notification_body_text(
request: SchedulingRequest,
participant: SchedulingParticipant | None,
event_kind: str,
) -> str:
greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello,"
lines = [greeting, "", _notification_body_intro(request, event_kind)]
selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None)
if selected_slot is not None:
lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}")
if request.location:
lines.append(f"Location: {request.location}")
lines.append(f"Scheduling request: {request.title}")
return "\n".join(lines)
def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str:
if event_kind == "invitation":
return f"You are invited to respond to the scheduling poll for {request.title}."
if event_kind == "reminder":
return f"This is a reminder to respond to the scheduling poll for {request.title}."
if event_kind == "decision":
return f"A final slot was selected for {request.title}."
if event_kind == "cancellation":
return f"The scheduling request {request.title} was cancelled."
return f"There is an update for {request.title}."
def _emit_scheduling_center_notification(
session: Session,
*,
request: SchedulingRequest,
event_kind: str,
subject: str,
body_text: str,
participant: SchedulingParticipant | None = None,
priority: int = 0,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=request.tenant_id,
source_module="scheduling",
source_resource_type="request",
source_resource_id=request.id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if request.organizer_user_id else None,
recipient_id=request.organizer_user_id,
subject=subject,
body_text=body_text,
action_url=f"/scheduling?request_id={request.id}",
priority=priority,
payload=_jsonable(
{
"request_id": request.id,
"poll_id": request.poll_id,
"participant_id": participant.id if participant else None,
}
),
metadata={"scheduling_request_id": request.id},
),
enqueue_delivery=False,
)
except Exception:
return
def list_scheduling_notifications( def list_scheduling_notifications(
session: Session, session: Session,
*, *,
@@ -414,6 +579,14 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR
if participant is not None and participant.status != "responded": if participant is not None and participant.status != "responded":
participant.status = "responded" participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at) participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session,
request=request,
participant=participant,
event_kind="scheduling.participant_responded",
subject=f"Scheduling response: {request.title}",
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
)
changed = True changed = True
if changed: if changed:
session.flush() session.flush()

View File

@@ -15,6 +15,7 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll", manifest.dependencies) self.assertIn("poll", manifest.dependencies)
self.assertNotIn("access", manifest.dependencies) self.assertNotIn("access", manifest.dependencies)
self.assertIn("access", manifest.optional_dependencies) self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertFalse(manifest.required_capabilities) self.assertFalse(manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies) self.assertIn("evaluation", manifest.optional_dependencies)
@@ -24,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -161,8 +161,33 @@ 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[];
};
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) }); const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
}
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> { export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
const query = status ? `?status=${encodeURIComponent(status)}` : ""; const query = status ? `?status=${encodeURIComponent(status)}` : "";
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`); return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react"; import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { Button, EmailAddressInput, hasScope, type ApiSettings, type AuthInfo, type MailboxAddress } from "@govoplan/core-webui";
import { import {
closeSchedulingRequest, closeSchedulingRequest,
createSchedulingCalendarEvent, createSchedulingCalendarEvent,
@@ -11,6 +11,7 @@ import {
evaluateSchedulingFreeBusy, evaluateSchedulingFreeBusy,
listSchedulingNotifications, listSchedulingNotifications,
listSchedulingRequests, listSchedulingRequests,
lookupSchedulingAddresses,
openSchedulingRequest, openSchedulingRequest,
schedulingSummary, schedulingSummary,
type SchedulingNotification, type SchedulingNotification,
@@ -51,6 +52,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) } { label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
]); ]);
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]); const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
const [addressLookupQuery, setAddressLookupQuery] = useState("");
const [addressSuggestions, setAddressSuggestions] = useState<MailboxAddress[]>([]);
const canWrite = hasScope(auth, "scheduling:schedule:write"); const canWrite = hasScope(auth, "scheduling:schedule:write");
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]); const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
@@ -73,6 +76,37 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
void loadDetails(selected.id); void loadDetails(selected.id);
}, [selected?.id]); }, [selected?.id]);
useEffect(() => {
const query = addressLookupQuery.trim();
if (!canWrite || query.length < 2) {
setAddressSuggestions([]);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void lookupSchedulingAddresses(settings, query).
then((response) => {
if (cancelled) return;
if (!response.available) {
setAddressSuggestions([]);
return;
}
setAddressSuggestions(
response.candidates
.map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null)
.filter((address): address is MailboxAddress => Boolean(address?.email))
);
}).
catch(() => {
if (!cancelled) setAddressSuggestions([]);
});
}, 200);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [addressLookupQuery, canWrite, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadRequests() { async function loadRequests() {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -258,7 +292,22 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
{participants.map((participant, index) => ( {participants.map((participant, index) => (
<div className="scheduling-participant-draft" key={index}> <div className="scheduling-participant-draft" key={index}>
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} /> <input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
<input placeholder="Email" value={participant.email} onChange={(event) => updateParticipant(index, "email", event.target.value)} /> <EmailAddressInput
value={participant.email ? [{ name: participant.display_name || undefined, email: participant.email }] : []}
suggestions={addressSuggestions}
onSuggestionQueryChange={setAddressLookupQuery}
allowMultiple={false}
disabled={!canWrite}
compact
addLabel="Add participant"
emptyText="Participant email"
onChange={(addresses) => {
const address = addresses[0];
updateParticipantDraft(index, {
display_name: address?.name ?? participant.display_name,
email: address?.email ?? ""
});
}} />
</div> </div>
))} ))}
</div> </div>
@@ -333,6 +382,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) { function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item))); setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
} }
function updateParticipantDraft(index: number, patch: Partial<ParticipantDraft>) {
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
}
} }
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } { function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {

View File

@@ -12,9 +12,9 @@ export const schedulingModule: PlatformWebModule = {
label: "Scheduling", label: "Scheduling",
version: "1.0.0", version: "1.0.0",
dependencies: ["poll"], dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"], optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations, translations: generatedTranslations,
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }], navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [ routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) } { path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
] ]