08-portals-and-i18nwave: W5filled15 citations

Receptionist Portal — Appointment and Waiting Room Management

Audiences: clinical-buyer, developer

Receptionist Portal — Appointment and Waiting Room Management

Front-desk workspace: today's dashboard with appointment + waiting-room stats, appointment creation/rescheduling (including recurring), patient check-in, calendar, patient registration, communications, and document handling — gated to RECEPTIONIST_ONLY.

Business Purpose

The receptionist is the orchestrator of the clinic day. Every appointment moves through their hands: booking, rescheduling, the call when someone runs late, the check-in when the patient arrives, the handoff to the waiting room, and the billing intake once the visit ends. The receptionist portal collapses what used to be a phone + paper + sticky note workflow into one surface that drives the same state machine the nurse and doctor see.

For the clinic, it is the throughput lever — a receptionist who can check in five patients per minute saves the practice an FTE. For the patient, it's the difference between waiting 20 minutes at the desk and walking straight into a triage room. For the doctor, it's confidence that the schedule reflects reality.

Audiences

  • Clinical buyer (clinic admin): shows the front-desk productivity surface — single pane for appointments + check-in + waiting room + recurring scheduling + patient registration.
  • Doctor + nurse: confidence that the visible schedule mirrors what the desk is actually doing (same source of truth, real-time SSE).
  • Developer/partner: explains the receptionist routes, the RECEPTIONIST_ONLY gate, the BackendStatusProvider (children scope), and the Trusted Gateway data pattern used by the dashboard and check-in pages.
  • Internal (ops/support): where to look when "today's count is wrong" or "check-in says 0 patients" reports come in.

Architecture

Routes

diagram
portal/receptionist/
├── layout.tsx                  → createRoleLayout(RECEPTIONIST_CONFIG, ReceptionistNav)
├── page.tsx                    → root entry
├── dashboard/                  → today's appointments + waiting-room stats
├── appointments/               → list, new, by id
│   ├── new/                    → create appointment form
│   └── [id]/                   → appointment detail
├── recurring-appointments/     → repeating schedule editor
├── calendar/                   → calendar grid view
├── check-in/                   → today's SCHEDULED/CONFIRMED/ARRIVED list
├── waiting-room/               → waiting-room triage (read-only view; nurse owns transitions)
├── patients/                   → roster + registration
├── communications/, messages/, → outbound + inbox
│   messenger/
├── notes/                      → desk notes
├── documents/                  → upload/download surface
├── forms/                      → fillable forms inbox
├── ai/                         → AI tooling entry
├── flowagent/                  → conversational flows
├── vivoxx/                     → voice (LiveKit) surface
├── settings/                   → preferences
├── profile/                    → identity
└── help/                       → help center

Layout composition

ddx-web/src/app/[locale]/portal/receptionist/layout.tsx:15-19:

typescript
function ReceptionistNav() { return <PortalNavigation roleKey="receptionist" />; }
export default createRoleLayout(RECEPTIONIST_CONFIG, ReceptionistNav);

RECEPTIONIST_CONFIG (ddx-web/src/lib/portal/role-configs.ts:134-146): authRequirement: 'RECEPTIONIST_ONLY', roleBadge.icon: ClipboardTextIcon, variant: 'info', brandNameKey: 'portals.receptionist', needsBackendStatus: true, backendStatusScope: 'children'.

The children scope is distinctive: the BackendStatusProvider wraps children inside the shell, not the entire shell (which doctor uses). Practical effect: a degraded backend shows the alert within the page area, not in the header banner. Wired at ddx-web/src/components/portal/createRoleLayout.tsx:95-98 (when backendStatusScope === 'children', innerChildren is wrapped via wrapBackendStatus).

Trusted Gateway data fetches

Receptionist pages follow the BFF (Trusted Gateway) pattern strictly — every backend call is a server-side backendGet / backendFetch from @/lib/api/backend-client, never a browser fetch. The dashboard at ddx-web/src/app/[locale]/portal/receptionist/dashboard/page.tsx:37-58 is the canonical example:

typescript
const [dashRes, statsRes] = await Promise.all([
  backendGet<DayDashboardResponse>(`appointments/dashboard/day?date=${today}`).catch(() => null),
  backendGet<WaitingRoomStatsResponse>('waiting-room/stats').catch(() => null),
]);

The check-in page (check-in/page.tsx, 88 lines) follows the same pattern for today's SCHEDULED/CONFIRMED/ARRIVED appointments.

Per-page role gate

Unlike the nurse portal, receptionist pages are consistent about per-page requireRoleAccess('RECEPTIONIST_ONLY', locale) in addition to the layout gate (e.g. dashboard/page.tsx:39, check-in/page.tsx). This defense-in-depth pattern catches any future layout misconfig at the page level.

Tech Stack & Choices

LayerChoiceWhy
Layout factorycreateRoleLayout(RECEPTIONIST_CONFIG, ReceptionistNav)Symmetric eleven-portal shell.
NavigationPortalNavigation roleKey="receptionist"Shared component; reception-specific nav config in lib/portal/nav-configs.
Role gateRECEPTIONIST_ONLY at layout and at each pageDefense-in-depth; catches layout regressions.
Backend statusneedsBackendStatus: true, scope: 'children'A degraded backend should show inline, not in the header — receptionist surfaces are heavy on live data.
Brand iconClipboardTextIcon (info variant)Front-desk clipboard semantic; distinct from clinical icons.
Data fetchingbackendGet / backendFetch server-sideTrusted Gateway Pattern (BFF only); never a browser fetch.
Date formattingdate-fns format(today, 'yyyy-MM-dd')Predictable query params for the dashboard endpoint.
i18n namespacereceptionist (+ sub-namespaces receptionist.pageTitles.*)One namespace per role.

Rejected: client-side polling for live counts (would multiply backend load and break under degraded mode), shared dashboard with the doctor (different data shapes; shared component would be over-coupled).

Data Flow

  1. Receptionist logs in → JWT carries role: RECEPTIONIST.
  2. Browser hits /<locale>/portal/receptionist/dashboard.
  3. proxy.ts:248-319 — locale + session gate.
  4. portal/receptionist/layout.tsx:19 runs createRoleLayout(RECEPTIONIST_CONFIG, ReceptionistNav): requireRoleAccess('RECEPTIONIST_ONLY', locale) returns the user, organization is fetched, the shell mounts with the children-scoped BackendStatusProvider wrapping the page area.
  5. dashboard/page.tsx:37-58 calls requireRoleAccess('RECEPTIONIST_ONLY', locale) again (page-level defense), then runs the two backendGet calls in parallel (Promise.all for appointments/dashboard/day and waiting-room/stats), tolerating failure with .catch(() => null). The result is shaped into a DashboardStats object and handed to <ReceptionistDashboardClient locale userName stats>.
  6. Check-in page fetches today's SCHEDULED/CONFIRMED/ARRIVED appointments server-side, hands them to <ReceptionistCheckInClient> for click-to-arrive UX.
  7. Once a patient is marked arrived, the appointment moves to status ARRIVED; the nurse portal's waiting-room view picks it up via SSE.

Implicated Code

  • ddx-web/src/app/[locale]/portal/receptionist/layout.tsx:15-19ReceptionistNav + createRoleLayout.
  • ddx-web/src/lib/portal/role-configs.ts:134-146RECEPTIONIST_CONFIG (needsBackendStatus, backendStatusScope: 'children').
  • ddx-web/src/components/portal/createRoleLayout.tsx:95-98children-scope BackendStatusProvider wrapping logic.
  • ddx-web/src/app/[locale]/portal/receptionist/dashboard/page.tsx:37-58 — Trusted Gateway Promise.all data fetch + client delegation.
  • ddx-web/src/app/[locale]/portal/receptionist/check-in/page.tsx:1-40 — check-in Server Component, CheckInAppointment DTO mirrors the backend enrichment shape.
  • ddx-web/src/lib/api/rbac-config.ts:37RECEPTIONIST: RECEPTIONIST_PERMISSIONS registration.
  • ddx-web/src/app/[locale]/portal/receptionist/calendar/page.tsx (89 lines) — calendar grid Server Component.

Operational Notes

  • backendStatusScope: 'children' is intentional — the alert renders inline in the page area, not in the header. Switching to 'shell' would change UX and is a reviewer-flag for the design-system owner.
  • .catch(() => null) on dashboard fetches — both appointments/dashboard/day and waiting-room/stats tolerate failure so the dashboard renders zeros instead of a crash. If the receptionist reports "always shows 0", check the network response in the BFF logs — the page hides backend errors by design.
  • Page-level role gate is the standard — receptionist pages call requireRoleAccess('RECEPTIONIST_ONLY', locale) explicitly. Keep this when adding new pages.
  • Status transitions — receptionist marks ARRIVED; nurse advances through the waiting-room state machine. The receptionist surface should be read-mostly for post-arrival states. Cross-role write would break the handoff invariant.
  • Date queries — always format(date, 'yyyy-MM-dd') from date-fns (no raw toISOString().split('T')[0] — timezone surprises). The dashboard pins to Europe/Berlin via the global next-intl config (ddx-web/src/lib/i18n/config.ts:157).
  • Recurring appointmentsrecurring-appointments/ is a distinct route from appointments/. Editing recurrence rules from the single-appointment route is not supported; deep-link from the recurring view instead.