medOS ultra

Screening Clinic Config

Per-clinic config of which screening tabs render and which layout, for the patient-history screen.

6 min read diagramsUpdated 2026-05-13docs/architecture/screening-clinic-config.md

Per-clinic configuration of which screening tabs render and which layout variant the UI uses, with admin CRUD at /super-admin/screening-config.

Why this exists

Until this change, the screening screen (ScreeningV2.tsx — “ซักประวัติผู้ป่วย” / “ข้อมูลการคัดกรอง”) hardcoded 51 Thai clinic UUIDs in web/src/setup/data/screening/screening.ts (checkAdditionalVitalSignNewScreeningV2) and web/src/setup/data/screening/useClinicFields.ts. Two consequences:

  1. PH / JP deployments silently render only the 4 base tabs because their subclinic UUIDs don’t match any of the 51 Thai UUIDs.
  2. There was no way to change the layout — every clinic got a horizontal tab strip on every device, with no responsive variant.

This change replaces both with a Supabase-backed config + an MVC abstraction of the view layer.

Master toggle (super-admin kill switch)

Before anything per-clinic kicks in, a single super-admin toggle decides whether the new system is active at all. This lets ops roll the feature out gradually and roll it back in one click.

Backed by screening_master_config — a singleton table (one row, id = 1):

column type default what it does
clinic_config_first bool FALSE OFF = ignore every clinic_screening_config row, force legacy hardcoded mapping for everyone. ON = per-clinic rows drive the UI.
default_layout_variant enum tabs Layout used for clinics that have no Supabase row (or for every clinic when the master toggle is OFF).
ai_assist_globally_enabled bool FALSE Global AI panel kill switch. A clinic row’s ai_assist_enabled = true is only honored when this is also true.

The default-OFF stance means installing this migration does not change behavior until a super-admin opts in from /super-admin/screening-config.

The hook (useScreeningConfig) consults the master row first:

clinic_config_first = FALSE → source = 'master-disabled'  (legacy mapping)
clinic_config_first = TRUE  + clinic row exists → source = 'supabase'
clinic_config_first = TRUE  + no row            → source = 'legacy-hardcoded'

When OFF, the per-clinic queries don’t even fire (React Query enabled: false) — zero round trips, zero risk.

UI: the top of ScreeningConfigPage shows a prominent master-toggle card (green when ON, grey when OFF) with the global default-layout picker and the AI kill switch alongside.

Model — Supabase tables

Migrations:

screening_tab_catalog

Vocabulary of tab types. Seeded with 15 entries (4 base + 10 specialty + 1 reserved ai-assist). Admins can extend at runtime.

column notes
id slug — vital-signs, obstetric, dental, …
display_th / display_en bilingual labels
component_key maps to the existing React tab components inside RenderTraumaEr
category base | specialty | ai-assist
default_fields jsonb — field schema seed for new encounters

clinic_screening_config

One row per subClinic._id. Source of truth replacing the hardcoded mapping.

column notes
clinic_id unique, matches encounter subClinic._id
tab_ids ordered text[] — references screening_tab_catalog.id
layout_variant_desktop tabs | accordion | wizard | grid | ai-assist
layout_variant_tablet nullable → falls back to desktop
layout_variant_mobile nullable → falls back to tablet → desktop
ai_assist_enabled + ai_assist_provider + ai_assist_config reserved for Qwen/OpenAI
enabled kill switch — off = fall back to legacy hardcoded mapping
region TH/PH/JP/GLOBAL — market-pack scoping

The migration backfills the 51 hardcoded UUIDs so post-migration behavior is byte-identical to pre-migration.

View — pluggable layouts

web/packages/medical-kit/src/medical-record/components/workflows/patient-check-in/menu-tab/screening-v2/layouts/

Every layout component satisfies ScreeningLayoutProps:

interface ScreeningLayoutProps {
  tabs: ResolvedScreeningTab[];
  activeTabId: string;
  onTabChange: (id: string) => void;
  renderTabContent: (tab: ResolvedScreeningTab) => ReactNode;
  formik: FormikProps<any>;
  editMode: boolean;
  breakpoint: 'mobile' | 'tablet' | 'desktop';
  aiAssist?: AiAssistContext;
  header?: ReactNode;
  footer?: ReactNode;
}
Variant File Best for
tabs TabsLayout.tsx Desktop — classic strip + single visible panel (preserves legacy UX)
accordion AccordionLayout.tsx Tablet / mobile — every section expandable inline
wizard WizardLayout.tsx Kiosk / mobile — sequential stepper, one tab at a time
grid GridLayout.tsx Tablet read-mostly — cards in a responsive grid
ai-assist AiAssistLayout.tsx Future — split TabsLayout + Qwen chat panel (stub today)

Add a new layout by creating a component, registering it in layouts/index.ts, and extending the CHECK constraint in the migration.

Responsive

Each layout consumes the breakpoint prop and adjusts internally. On top of that, admins can override which variant to use per breakpoint (layout_variant_mobile / _tablet / _desktop). A clinic might use wizard on mobile, accordion on tablet, and tabs on desktop — all from one config row.

Controller — hook + renderer

  • useScreeningConfig(clinicId) — React Query hook. Resolves the active layout variant for the current viewport, the list of tabs, and the AI context. Three fallback layers:
    1. screening_config row in Supabase (preferred).
    2. Legacy hardcoded mapping from checkAdditionalVitalSignNewScreeningV2.
    3. Base 4 tabs only.
  • ScreeningLayoutRenderer — picks the layout component from SCREENING_LAYOUTS and mounts it.

Admin UI

Route: /super-admin/screening-config (registered in routes-integrated.tsx; card on the super-admin dashboard).

Components:

  • ScreeningConfigPage.tsx — list of all clinic configs, enabled toggle, search, edit/delete actions.
  • ScreeningConfigDialog.tsx — edit drawer with tab picker (re-orderable), per-breakpoint layout pickers, AI block, and an inline live preview.
  • ScreeningPreview.tsx — renders the chosen layout with stub content + a mobile/tablet/desktop viewport toggle.

What’s wired vs. what’s a follow-up

✅ Wired today

  • Admin can CRUD config rows; React Query caches propagate live.
  • ScreeningV2.tsx consumes useScreeningConfig and overrides its listTab state when a Supabase row exists for the encounter’s subClinic. Clinics without a row keep the legacy hardcoded behavior.
  • All 5 layout components exist, registered, and previewable from the admin.
  • Migration backfills the 51 legacy UUIDs so pre/post behavior is byte-identical.

🔜 Deferred follow-ups

  • Render-path swap. ScreeningV2.tsx still renders inside the legacy TabsLayout-shaped chrome regardless of which `layout_variant_*` the admin picks. Honoring `accordion`/`wizard`/`grid` variants at runtime in this screen requires swapping the inner render to, which touches the formik shape and tab content components. The renderer and layouts are ready — the swap was deferred to keep this PR minimal and not regress the live screening flow. The picked variant IS already visible in the admin live preview.
  • AI Qwen integration. AiAssistLayout.tsx renders the chat shell but does not yet stream from a provider. The plan is to wire ai_assist_provider to a thin adapter (qwen | openai | azure-openai | custom) that supports function-calling tools mapped to formik fields on the active tab.
  • Realtime subscription. Admin edits propagate via React Query invalidation. A future useScreeningConfigRealtime (mirroring usePolicyGatesRealtime) would push edits to open encounters without a page refresh.
  • Catalog admin UI. Editing screening_tab_catalog is via Supabase service role today. A simple CRUD card on the admin page would let admins add custom specialty tabs without SQL.

How to deploy to a new region

  1. Apply migration 20260513_screening_clinic_config.sql to the region’s Supabase project (SQL editor, or psql -f).
  2. (Optional) Seed region-specific clinic rows. If you skip this, the region falls back to the 4 base tabs — admins can then add rows via the UI.
  3. Build & deploy the frontend as normal. The admin route + dashboard card are gated to super-admin users only.
Ask Anything