Screening Clinic Config
Per-clinic config of which screening tabs render and which layout, for the patient-history screen.
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:
- PH / JP deployments silently render only the 4 base tabs because their subclinic UUIDs don’t match any of the 51 Thai UUIDs.
- 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:
20260513_screening_clinic_config.sql— per-clinic config + tab catalog20260513_screening_master_config.sql— singleton master toggle
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
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:screening_configrow in Supabase (preferred).- Legacy hardcoded mapping from
checkAdditionalVitalSignNewScreeningV2. - Base 4 tabs only.
ScreeningLayoutRenderer— picks the layout component fromSCREENING_LAYOUTSand 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.tsxconsumesuseScreeningConfigand overrides itslistTabstate 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.tsxstill renders inside the legacyTabsLayout-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.tsxrenders the chat shell but does not yet stream from a provider. The plan is to wireai_assist_providerto 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(mirroringusePolicyGatesRealtime) would push edits to open encounters without a page refresh. - Catalog admin UI. Editing
screening_tab_catalogis 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
- Apply migration
20260513_screening_clinic_config.sqlto the region’s Supabase project (SQL editor, orpsql -f). - (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.
- Build & deploy the frontend as normal. The admin route + dashboard card are gated to super-admin users only.