diff --git a/docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md b/docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md new file mode 100644 index 0000000..ef1268e --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md @@ -0,0 +1,340 @@ +# Hospital Onboarding & Self-Service Setup + +**Date:** 2026-04-06 +**Status:** Plan — pending implementation +**Owner:** helix-engage + +--- + +## Goal + +Make onboarding a new hospital a one-command devops action plus a guided self-service flow inside the staff portal. After running the script, the hospital admin should be able to log into a fresh workspace and reach a fully operational call center by filling in 6 setup pages — without anyone touching env vars, JSON files, or running shell commands a second time. + +## Non-goals + +- Per-tenant secrets management (env vars stay infra-owned for now). +- Self-service Cloudflare Turnstile / Ozonetel account provisioning. Operator pastes pre-existing credentials. +- Multi-hospital routing inside one sidecar. One sidecar per workspace; multi-tenancy is handled by the platform. +- Bulk CSV import of doctors / staff. Single-row form CRUD only. +- Email infrastructure for invitations beyond what core already does. + +--- + +## User journey + +### T0 — devops, one-command bootstrap (~30 seconds) + +```bash +./onboard-hospital.sh \ + --create \ + --display-name "Care Hospital" \ + --subdomain care \ + --admin-email admin@carehospital.com \ + --admin-password 'TempCare#2026' +``` + +Script signs up the admin user, creates and activates the workspace, syncs the helix-engage SDK, mints an API key, writes a sidecar `.env`, and prints a credentials handoff block. Done. + +### T1 — hospital admin first login (~10 minutes) + +Admin opens the workspace URL, signs in with the temp password. App detects an unconfigured workspace and routes them to `/setup`. A 6-step wizard walks them through: + +1. **Hospital identity** — confirm display name, upload logo, pick brand colors → writes to `theme.json` +2. **Clinics** — add at least one branch (name, address, phone, timings) → creates Clinic records on platform +3. **Doctors** — add at least one doctor (name, specialty, clinic, visiting hours) → creates Doctor records on platform +4. **Team** — invite supervisors and CC agents by email → triggers core's `sendInvitations` mutation +5. **Telephony** — paste Ozonetel + Exotel credentials, default DID, default campaign → writes to `telephony.json` +6. **AI assistant** — pick provider (OpenAI / Anthropic), model, optional system prompt override → writes to `ai.json` + +After step 6, admin clicks "Finish setup" and lands on the home dashboard. Setup state is recorded in `setup-state.json` so the wizard never auto-shows again. + +### T2 — hospital admin returns later (any time) + +Each setup page is also accessible standalone via the **Settings** menu. Admin can edit any of them at any time. Settings hub shows green checkmarks for completed sections and yellow badges for sections still using defaults. + +### T3 — agents and supervisors join + +Supervisors and agents accept their invitation emails, set their own passwords, and land on the home dashboard. They're already role-assigned by the admin during T1 step 4, so they see the right pages immediately. + +--- + +## Architecture decisions + +### 1. Script does identity. Portal does configuration. + +- **In script:** anything requiring platform-admin credentials (signup, workspace activation, SDK sync, API key creation). One-time, devops-only. +- **In staff portal:** anything that operates inside the workspace (clinics, doctors, team, sidecar config files). Self-serve, repeatable. + +This keeps the script's blast radius small and means the hospital admin never needs platform-admin access. + +### 2. Two distinct frontend → backend patterns + +**Pattern A — Direct GraphQL to platform** (for entities the platform owns) +- Clinics, Doctors, Workspace Members +- Frontend uses `apiClient.graphql(...)` with the user's JWT +- Already established by `settings.tsx` for member listing +- No sidecar code needed + +**Pattern B — Sidecar admin endpoints** (for sidecar-owned config files) +- Theme (`theme.json`), Widget (`widget.json`), Telephony (`telephony.json`), AI (`ai.json`), Setup state (`setup-state.json`) +- Frontend uses `apiClient.fetch('/api/config/...')` +- Sidecar persists to disk via `*ConfigService` mirroring `ThemeService` +- Already established by `branding-settings.tsx` and `WidgetConfigService` + +**Rule:** if it lives in a workspace schema on the platform, use Pattern A. If it's a sidecar config file, use Pattern B. Don't mix. + +### 3. Telephony config moves out of env vars + +`OZONETEL_*`, `SIP_*`, `EXOTEL_*` env vars become bootstrap defaults that seed `data/telephony.json` on first boot, then never read again. All runtime reads go through `TelephonyConfigService.getConfig()`. Six read sites refactor (auth.controller, ozonetel-agent.service, ozonetel-agent.controller, kookoo-ivr.controller, agent-config.service, maint.controller). + +### 4. AI config moves out of env vars + +Same pattern. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` stay in env (true secrets), but `AI_PROVIDER` / `AI_MODEL` move to `data/ai.json`. `WidgetChatService` and any other AI-using services read from `AiConfigService`. + +### 5. Setup state lives in its own file + +`data/setup-state.json` tracks completion status for each of the 6 setup steps + a global `wizardDismissed` flag. Frontend reads it on app load to decide whether to show the setup wizard. Each setup page marks its step complete on save. + +```json +{ + "version": 1, + "wizardDismissed": false, + "steps": { + "identity": { "completed": false, "completedAt": null }, + "clinics": { "completed": false, "completedAt": null }, + "doctors": { "completed": false, "completedAt": null }, + "team": { "completed": false, "completedAt": null }, + "telephony": { "completed": false, "completedAt": null }, + "ai": { "completed": false, "completedAt": null } + } +} +``` + +### 6. Member invitation stays email-based + +The platform only supports email-based invitations (`sendInvitations` → email link → invitee sets password). We use this as-is. No new core mutation. Trade-off: admin can't bulk-create users with pre-set passwords, but the email flow is acceptable for hospital onboarding (admin types in the team's emails once, they each get a setup link). + +### 7. Roles are auto-synced by SDK + +`HelixEngage Manager` and `HelixEngage User` roles are defined in `FortyTwoApps/helix-engage/src/roles/` and created automatically by `yarn app:sync`. The frontend's role dropdown in the team invite form queries the platform for the workspace's roles via `getRoles` and uses real role IDs (no email-pattern hacks). + +--- + +## Backend changes (helix-engage-server) + +### New services / files + +| File | Purpose | +|---|---| +| `src/config/setup-state.defaults.ts` | Type + defaults for `data/setup-state.json` | +| `src/config/setup-state.service.ts` | Load / get / mark step complete / dismiss wizard | +| `src/config/telephony.defaults.ts` | Type + defaults for `data/telephony.json` (Ozonetel + Exotel + SIP) | +| `src/config/telephony-config.service.ts` | File-backed CRUD; `onModuleInit` seeds from env vars on first boot | +| `src/config/ai.defaults.ts` | Type + defaults for `data/ai.json` | +| `src/config/ai-config.service.ts` | File-backed CRUD; seeds from env on first boot | +| `src/config/setup-state.controller.ts` | `GET /api/config/setup-state`, `PUT /api/config/setup-state/steps/:step`, `POST /api/config/setup-state/dismiss` | +| `src/config/telephony-config.controller.ts` | `GET/PUT /api/config/telephony` with secret masking on GET | +| `src/config/ai-config.controller.ts` | `GET/PUT /api/config/ai` with secret masking | + +### Modified files + +| File | Change | +|---|---| +| `src/config/config-theme.module.ts` | Register the 3 new services + 3 new controllers | +| `src/config/widget.defaults.ts` | Drop `hospitalName` field (the long-standing duplicate) | +| `src/config/widget-config.service.ts` | Inject `ThemeService`, read `brand.hospitalName` from theme at the 2 generateKey call sites | +| `src/widget/widget.service.ts` | `getInitData()` reads captcha site key from `WidgetConfigService` instead of `process.env.RECAPTCHA_SITE_KEY` | +| `src/auth/agent-config.service.ts:49` | Read `OZONETEL_CAMPAIGN_NAME` from `TelephonyConfigService` | +| `src/auth/auth.controller.ts:141, 255` | Read `OZONETEL_AGENT_PASSWORD` from `TelephonyConfigService` | +| `src/ozonetel/ozonetel-agent.service.ts:199, 235, 236` | Read `OZONETEL_DID`, `OZONETEL_SIP_ID` from `TelephonyConfigService` | +| `src/ozonetel/ozonetel-agent.controller.ts:39, 42, 192` | Same | +| `src/ozonetel/kookoo-ivr.controller.ts:11, 12` | Same | +| `src/maint/maint.controller.ts:27` | Same | +| `src/widget/widget-chat.service.ts` | Read `provider` and `model` from `AiConfigService` instead of `ConfigService` | +| `src/ai/ai-provider.ts` | Same — provider/model from config file, API keys still from env | + +--- + +## Frontend changes (helix-engage) + +### New pages + +| Page | Path | Purpose | +|---|---|---| +| `setup/setup-wizard.tsx` | `/setup` | 6-step wizard, auto-shown on first login when setup incomplete | +| `pages/clinics.tsx` | `/settings/clinics` | List + add/edit clinic records (slideout pattern) | +| `pages/doctors.tsx` | `/settings/doctors` | List + add/edit doctors, assign to clinics | +| `pages/team-settings.tsx` | `/settings/team` | Member list + invite form + role editor (replaces current `settings.tsx` member view) | +| `pages/telephony-settings.tsx` | `/settings/telephony` | Ozonetel + Exotel + SIP form (consumes `/api/config/telephony`) | +| `pages/ai-settings.tsx` | `/settings/ai` | AI provider/model/prompt form (consumes `/api/config/ai`) | +| `pages/widget-settings.tsx` | `/settings/widget` | Widget enabled/embed/captcha form (consumes `/api/config/widget`) | +| `pages/settings-hub.tsx` | `/settings` | Index page listing all setup sections with completion badges. Replaces current `settings.tsx`. | + +### Modified pages + +| File | Change | +|---|---| +| `src/pages/login.tsx` | After successful login, fetch `/api/config/setup-state`. If incomplete and user is workspace admin, redirect to `/setup`. Otherwise existing flow. | +| `src/pages/branding-settings.tsx` | On save, mark `identity` step complete via `PUT /api/config/setup-state/steps/identity` | +| `src/components/layout/sidebar.tsx` | Add Settings hub entry; remove direct links to individual settings pages from main nav (move them under Settings) | +| `src/providers/router-provider.tsx` | Register the 7 new routes | +| `src/pages/integrations.tsx` | Remove the Ozonetel + Exotel cards (functionality moves to `telephony-settings.tsx`); keep WhatsApp/FB/Google/website cards for now | + +### New shared components + +| File | Purpose | +|---|---| +| `src/components/setup/wizard-shell.tsx` | Layout: progress bar, step navigation, footer with prev/next | +| `src/components/setup/wizard-step.tsx` | Single-step container — title, description, content slot, validation hook | +| `src/components/setup/section-card.tsx` | Settings hub section card with status badge | +| `src/components/forms/clinic-form.tsx` | Reused by clinics page + setup wizard step 2 | +| `src/components/forms/doctor-form.tsx` | Reused by doctors page + setup wizard step 3 | +| `src/components/forms/invite-member-form.tsx` | Reused by team page + setup wizard step 4 | +| `src/components/forms/telephony-form.tsx` | Reused by telephony settings + setup wizard step 5 | +| `src/components/forms/ai-form.tsx` | Reused by ai settings + setup wizard step 6 | + +The pattern: each settings page renders the same form component the wizard step renders. Wizard steps just wrap the form in `` and add prev/next navigation. Standalone settings pages wrap the form in a normal page layout. Form is the source of truth; wizard and settings page are two presentations of the same thing. + +--- + +## Onboarding script changes + +`onboard-hospital.sh` is already 90% there. Three minor changes: + +1. **Drop the `--sidecar-env-out` default behavior** — print a structured "credentials handoff" block at the end with admin email, temp password, workspace URL, sidecar `.env` content. Operator copies what they need. +2. **Change the credentials block format** — make it copy-pasteable as a single email body so the operator can email it to the hospital owner directly. +3. **Add `setup-state.json` initialization** — the script writes a fresh `setup-state.json` to the sidecar's `data/` directory as part of step 6, so the first frontend load knows nothing is configured yet. + +--- + +## Phasing + +Each phase is a coherent commit. Don't ship phases out of order. + +### Phase 1 — Backend foundations (config services + endpoints) + +**Files:** 9 new + 4 modified backend files. No frontend. + +- New services: `setup-state`, `telephony-config`, `ai-config` +- New defaults files for each +- New controllers for each +- Module wiring +- Drop `widget.json.hospitalName` (the original duplicate that started this whole thread) +- Migrate the 6 Ozonetel read sites to `TelephonyConfigService` +- Migrate the AI provider/model reads to `AiConfigService` +- First-boot env-var seeding: each new service reads its respective env vars on `onModuleInit` and writes them to its config file if the file doesn't exist + +**Verifies:** sidecar still serves all existing endpoints, env-var-driven Ozonetel still works (because the seeding picks up the same values), `data/telephony.json` and `data/ai.json` exist on first boot. + +**Estimate:** 4-5 hours. + +### Phase 2 — Settings hub + first-run detection + +**Files:** 2 new pages + 4 modified frontend files + new shared `section-card` component. + +- `settings-hub.tsx` replaces `settings.tsx` as the `/settings` route +- Move the existing member-list view from `settings.tsx` into a new `team-settings.tsx` (read-only for now; invite + role editing comes in Phase 3) +- `login.tsx` fetches setup-state after successful login and redirects to `/setup` if incomplete +- `setup/setup-wizard.tsx` shell renders the 6 step containers (with placeholder content for now) +- Sidebar redesign: collapse all settings into one Settings entry that opens the hub +- Router updates to register the new routes + +**Verifies:** clean login → setup wizard appearance for fresh workspace; Settings hub navigates to existing pages; nothing breaks for already-set-up workspaces. + +**Estimate:** 3-4 hours. + +### Phase 3 — Entity CRUD pages (Pattern A — direct platform GraphQL) + +**Files:** 3 new pages + 3 new form components + 1 modified team page. + +- `clinics.tsx` + `clinic-form.tsx` — list with add/edit slideout +- `doctors.tsx` + `doctor-form.tsx` — list with add/edit, clinic dropdown sourced from `clinics` +- `team-settings.tsx` becomes interactive — add invite form via `sendInvitations`, real role dropdown via `getRoles`, role assignment via `updateWorkspaceMemberRole` + +**Verifies:** admin can create clinics, doctors, and invite team members from the staff portal without touching the database. + +**Estimate:** 5-6 hours. + +### Phase 4 — Sidecar-config CRUD pages (Pattern B — sidecar admin endpoints) + +**Files:** 3 new pages + 3 new form components. + +- `telephony-settings.tsx` + `telephony-form.tsx` — Ozonetel + Exotel + SIP fields +- `ai-settings.tsx` + `ai-form.tsx` — provider, model, temperature, system prompt +- `widget-settings.tsx` + `widget-form.tsx` — wraps the existing widget config endpoint with a real form + +**Verifies:** admin can edit telephony, AI, and widget config from the staff portal. Changes take effect without sidecar restart (since services use in-memory cache + file write). + +**Estimate:** 4-5 hours. + +### Phase 5 — Wizard step composition + +**Files:** 6 wizard step components, each thin wrappers around the Phase 3/4 forms. + +- `wizard-step-identity.tsx` +- `wizard-step-clinics.tsx` +- `wizard-step-doctors.tsx` +- `wizard-step-team.tsx` +- `wizard-step-telephony.tsx` +- `wizard-step-ai.tsx` + +Each wraps the corresponding form, adds wizard validation (required fields enforced for setup completion), and on save calls `PUT /api/config/setup-state/steps/` to mark the step complete. + +**Verifies:** admin can complete the entire setup wizard end-to-end on a fresh workspace. After step 6, redirected to home dashboard. Setup state file shows all 6 steps complete. + +**Estimate:** 2-3 hours. + +### Phase 6 — Polish + +- Onboarding script credentials handoff block format +- "Resume setup" CTA on home dashboard if any step is incomplete +- Loading states, error toasts, optimistic updates +- Setup-state badges on the Settings hub +- Validation: clinic count > 0 required for booking flow, doctor count > 0 required for booking flow, etc. +- E2E smoke test against the Care Hospital workspace I already created + +**Estimate:** 2-3 hours. + +--- + +## Total estimate + +**20-26 hours of focused implementation work** spanning ~30 new files and ~15 modified files. Realistic over 3-4 working days with checkpoints at each phase boundary. + +--- + +## Out of scope (explicit) + +- Self-service Cloudflare Turnstile signup (operator pastes existing site key) +- Self-service Ozonetel account creation (operator pastes credentials) +- Bulk import of doctors / staff (single-row form only) +- Per-tenant secrets management (env vars stay infra-owned for AI keys, captcha secret, HMAC secret) +- Workspace deletion / archival +- Multi-hospital admin (one admin per workspace; switching workspaces is platform-level) +- Hospital templates ("clone from Ramaiah") — useful follow-up but not required for the first real onboarding +- Self-service password reset for invited members (handled by the existing platform reset-password flow) +- Onboarding analytics / metrics +- Email branding for invitation emails (uses platform default for now) + +--- + +## Open questions before phase 1 + +1. **Sidecar config file hot-reload** — when an admin updates `telephony.json` via the new endpoint, does the change need to take effect immediately (in-memory cache invalidation, no restart) or is a sidecar restart acceptable? Decision affects whether services need a "refresh" hook. **Recommendation: in-memory cache only, no restart needed** — already how `ThemeService` works. + +2. **Setup state visibility** — should the setup-state file be a simple flag set or should it track *who* completed each step and *when*? Recommendation: track `completedAt` timestamp + `completedBy` user id for audit trail. + +3. **Auto-mark "identity" step complete from existing branding** — if the workspace already has a `theme.json` with a non-default `brand.hospitalName`, should the wizard auto-skip step 1? **Recommendation: yes** — don't make admins re-confirm something they already configured. + +4. **What if the admin invites a team member who already exists on the platform?** Does `sendInvitations` add them to the workspace, or fail? Need to verify before Phase 3. If it fails, we may need a "find or invite" wrapper. + +5. **Logo upload** — do we accept a URL only (admin pastes a CDN link) or do we need real file upload to MinIO? **Recommendation: URL only for Phase 1**, file upload as Phase 6 polish. + +--- + +## Risks + +- **`yarn app:sync` may sometimes fail to register HelixEngage roles cleanly** if a workspace was activated but never had its first sync — this would block the team page's role dropdown. Mitigation: script runs sync immediately after activation, before exiting. +- **Frontend role queries require user JWT, not API key** — `settings.tsx` already noted this with the "Roles are only accessible via user JWT" comment. The team-settings page has to use direct GraphQL with user auth, not the sidecar proxy. +- **Migrating Ozonetel env vars to a config file mid-session can break a running sidecar** if someone's actively using the call desk during deploy. Mitigation: deploy during low-usage window; the new service falls back to env vars if the config file is missing. +- **Setup wizard auto-redirect could trap users in a loop** if `setup-state.json` write fails. Mitigation: wizard always has a "Skip for now" link in the top right that sets `wizardDismissed: true`. diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 54f8aba..3fa6708 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -18,7 +18,6 @@ import { faChartLine, faFileAudio, faPhoneMissed, - faSlidersUp, } from "@fortawesome/pro-duotone-svg-icons"; import { faIcon } from "@/lib/icon-wrapper"; import { useAtom } from "jotai"; @@ -53,7 +52,6 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast); const IconChartLine = faIcon(faChartLine); const IconFileAudio = faIcon(faFileAudio); const IconPhoneMissed = faIcon(faPhoneMissed); -const IconSlidersUp = faIcon(faSlidersUp); type NavSection = { label: string; @@ -79,10 +77,9 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Marketing', items: [ { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, ]}, - { label: 'Configuration', items: [ - { label: 'Rules Engine', href: '/rules', icon: IconSlidersUp }, - { label: 'Branding', href: '/branding', icon: IconGear }, - ]}, + // Settings hub absorbs branding, rules, team, clinics, doctors, + // telephony, ai, widget — one entry, navigates to the hub which + // links to each section page. { label: 'Admin', items: [ { label: 'Settings', href: '/settings', icon: IconGear }, ]}, diff --git a/src/components/setup/section-card.tsx b/src/components/setup/section-card.tsx new file mode 100644 index 0000000..5bc9346 --- /dev/null +++ b/src/components/setup/section-card.tsx @@ -0,0 +1,67 @@ +import { Link } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowRight, faCircleCheck, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons'; +import { cx } from '@/utils/cx'; + +type SectionStatus = 'complete' | 'incomplete' | 'unknown'; + +type SectionCardProps = { + title: string; + description: string; + icon: any; + iconColor?: string; + href: string; + status?: SectionStatus; +}; + +// Settings hub card. Each card represents one setup-able section (Branding, +// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its +// dedicated page. The status badge mirrors the wizard's setup-state so an +// admin can see at a glance which sections still need attention. +export const SectionCard = ({ + title, + description, + icon, + iconColor = 'text-brand-primary', + href, + status = 'unknown', +}: SectionCardProps) => { + return ( + +
+
+
+ +
+
+

{title}

+

{description}

+
+
+ +
+ + {status !== 'unknown' && ( +
+ {status === 'complete' ? ( + + + Configured + + ) : ( + + + Setup needed + + )} +
+ )} + + ); +}; diff --git a/src/components/setup/wizard-shell.tsx b/src/components/setup/wizard-shell.tsx new file mode 100644 index 0000000..2822ab9 --- /dev/null +++ b/src/components/setup/wizard-shell.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCircleCheck, faCircle } from '@fortawesome/pro-duotone-svg-icons'; +import { Button } from '@/components/base/buttons/button'; +import { cx } from '@/utils/cx'; +import { SETUP_STEP_NAMES, SETUP_STEP_LABELS, type SetupStepName, type SetupState } from '@/lib/setup-state'; + +type WizardShellProps = { + state: SetupState; + activeStep: SetupStepName; + onSelectStep: (step: SetupStepName) => void; + onDismiss: () => void; + children: ReactNode; +}; + +// Layout shell for the onboarding wizard. Renders a left-side step navigator +// (with completed/active/upcoming visual states) and a right-side content +// pane fed by the parent. The header has a "Skip for now" affordance that +// dismisses the wizard for this workspace — once dismissed it never auto-shows +// again on login. +export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, children }: WizardShellProps) => { + const completedCount = SETUP_STEP_NAMES.filter(s => state.steps[s].completed).length; + const totalSteps = SETUP_STEP_NAMES.length; + const progressPct = Math.round((completedCount / totalSteps) * 100); + + return ( +
+ {/* header */} +
+
+
+

Set up your hospital

+

+ {completedCount} of {totalSteps} steps complete · finish setup to start using your workspace +

+
+ +
+ {/* progress bar */} +
+
+
+
+
+
+ + {/* body — step navigator + content */} +
+ + +
{children}
+
+
+ ); +}; diff --git a/src/components/setup/wizard-step.tsx b/src/components/setup/wizard-step.tsx new file mode 100644 index 0000000..a73091b --- /dev/null +++ b/src/components/setup/wizard-step.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowLeft, faArrowRight, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { Button } from '@/components/base/buttons/button'; +import { SETUP_STEP_LABELS, type SetupStepName } from '@/lib/setup-state'; + +type WizardStepProps = { + step: SetupStepName; + isCompleted: boolean; + isLast: boolean; + onPrev: (() => void) | null; + onNext: (() => void) | null; + onMarkComplete: () => void; + onFinish: () => void; + saving?: boolean; + children: ReactNode; +}; + +// Single-step wrapper. The parent picks which step is active and supplies +// the form content as children. The step provides title, description, +// "mark complete" CTA, and prev/next/finish navigation. In Phase 5 the +// children will be real form components from the corresponding settings +// pages — for now they're placeholders. +export const WizardStep = ({ + step, + isCompleted, + isLast, + onPrev, + onNext, + onMarkComplete, + onFinish, + saving = false, + children, +}: WizardStepProps) => { + const meta = SETUP_STEP_LABELS[step]; + + return ( +
+
+
+

{meta.title}

+

{meta.description}

+
+ {isCompleted && ( + + + Complete + + )} +
+ +
{children}
+ +
+ + +
+ {!isCompleted && ( + + )} + {isLast ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 83ec8c4..b0e8339 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -212,6 +212,16 @@ export const apiClient = { return handleResponse(response, options?.silent, doFetch); }, + async put(path: string, body?: Record, options?: { silent?: boolean }): Promise { + const doFetch = () => fetch(`${API_URL}${path}`, { + method: 'PUT', + headers: authHeaders(), + body: body ? JSON.stringify(body) : undefined, + }); + const response = await doFetch(); + return handleResponse(response, options?.silent, doFetch); + }, + // Health check — silent, no toasts async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> { try { diff --git a/src/lib/setup-state.ts b/src/lib/setup-state.ts new file mode 100644 index 0000000..d876290 --- /dev/null +++ b/src/lib/setup-state.ts @@ -0,0 +1,83 @@ +import { apiClient } from './api-client'; + +// Mirror of the sidecar SetupState shape — keep in sync with +// helix-engage-server/src/config/setup-state.defaults.ts. Any change to the +// step list there must be reflected here. + +export type SetupStepName = + | 'identity' + | 'clinics' + | 'doctors' + | 'team' + | 'telephony' + | 'ai'; + +export const SETUP_STEP_NAMES: readonly SetupStepName[] = [ + 'identity', + 'clinics', + 'doctors', + 'team', + 'telephony', + 'ai', +] as const; + +export type SetupStepStatus = { + completed: boolean; + completedAt: string | null; + completedBy: string | null; +}; + +export type SetupState = { + version?: number; + updatedAt?: string; + wizardDismissed: boolean; + steps: Record; + wizardRequired: boolean; +}; + +// Human-friendly labels for the wizard UI + settings hub badges. Kept here +// next to the type so adding a new step touches one file. +export const SETUP_STEP_LABELS: Record = { + identity: { + title: 'Hospital Identity', + description: 'Confirm your hospital name, upload your logo, and pick brand colors.', + }, + clinics: { + title: 'Clinics', + description: 'Add your physical branches with addresses and visiting hours.', + }, + doctors: { + title: 'Doctors', + description: 'Add clinicians, assign them to clinics, and set their schedules.', + }, + team: { + title: 'Team', + description: 'Invite supervisors and call-center agents to your workspace.', + }, + telephony: { + title: 'Telephony', + description: 'Connect Ozonetel and Exotel for inbound and outbound calls.', + }, + ai: { + title: 'AI Assistant', + description: 'Choose your AI provider and customise the assistant prompts.', + }, +}; + +export const getSetupState = () => + apiClient.get('/api/config/setup-state', { silent: true }); + +export const markSetupStepComplete = (step: SetupStepName, completedBy?: string) => + apiClient.put(`/api/config/setup-state/steps/${step}`, { + completed: true, + completedBy, + }); + +export const markSetupStepIncomplete = (step: SetupStepName) => + apiClient.put(`/api/config/setup-state/steps/${step}`, { completed: false }); + +export const dismissSetupWizard = () => + apiClient.post('/api/config/setup-state/dismiss'); + +export const resetSetupState = () => + apiClient.post('/api/config/setup-state/reset'); diff --git a/src/main.tsx b/src/main.tsx index 733472a..6e11281 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,9 @@ import { ProfilePage } from "@/pages/profile"; import { AccountSettingsPage } from "@/pages/account-settings"; import { RulesSettingsPage } from "@/pages/rules-settings"; import { BrandingSettingsPage } from "@/pages/branding-settings"; +import { TeamSettingsPage } from "@/pages/team-settings"; +import { SetupWizardPage } from "@/pages/setup-wizard"; +import { SettingsPlaceholder } from "@/pages/settings-placeholder"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -49,6 +52,9 @@ createRoot(document.getElementById("root")!).render( } /> }> + {/* Setup wizard — fullscreen, no AppShell */} + } /> + @@ -74,7 +80,61 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + + {/* Settings hub + section pages */} } /> + } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + } /> } /> } /> diff --git a/src/pages/login.tsx b/src/pages/login.tsx index bdece77..86c966d 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -11,6 +11,7 @@ import { Input } from '@/components/base/input/input'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useThemeTokens } from '@/providers/theme-token-provider'; +import { getSetupState } from '@/lib/setup-state'; export const LoginPage = () => { const { loginWithUser } = useAuth(); @@ -114,6 +115,22 @@ export const LoginPage = () => { }); refresh(); + + // First-run detection: if the workspace's setup is incomplete and + // the wizard hasn't been dismissed, route the admin to /setup so + // they finish onboarding before reaching the dashboard. Failures + // are non-blocking — we always have a fallback to /. + try { + const state = await getSetupState(); + if (state.wizardRequired) { + navigate('/setup'); + return; + } + } catch { + // Setup state endpoint may be unreachable on older sidecars — + // proceed to the normal landing page. + } + navigate('/'); } catch (err: any) { setError(err.message); diff --git a/src/pages/settings-placeholder.tsx b/src/pages/settings-placeholder.tsx new file mode 100644 index 0000000..2df608a --- /dev/null +++ b/src/pages/settings-placeholder.tsx @@ -0,0 +1,47 @@ +import { useNavigate } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowLeft, faTools } from '@fortawesome/pro-duotone-svg-icons'; +import { Button } from '@/components/base/buttons/button'; +import { TopBar } from '@/components/layout/top-bar'; + +type SettingsPlaceholderProps = { + title: string; + description: string; + phase: string; +}; + +// Placeholder for settings pages that haven't been built yet. Used by routes +// the Settings hub links to during Phase 2 — Phase 3 (Clinics, Doctors, Team +// invite/role editor) and Phase 4 (Telephony, AI, Widget) replace these with +// real CRUD pages. +export const SettingsPlaceholder = ({ title, description, phase }: SettingsPlaceholderProps) => { + const navigate = useNavigate(); + return ( +
+ +
+
+
+ +
+

Coming in {phase}

+

+ This page will let you manage {title.toLowerCase()} directly from the staff portal. + It's not built yet — see the onboarding plan for delivery details. +

+ +
+
+
+ ); +}; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 952d4ce..d25eacc 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -1,184 +1,153 @@ -import { useEffect, useMemo, useState } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faKey, faToggleOn } from '@fortawesome/pro-duotone-svg-icons'; -import { Avatar } from '@/components/base/avatar/avatar'; -import { Badge } from '@/components/base/badges/badges'; -import { Button } from '@/components/base/buttons/button'; -import { Table, TableCard } from '@/components/application/table/table'; +import { useEffect, useState } from 'react'; +import { + faBuilding, + faStethoscope, + faUserTie, + faPhone, + faRobot, + faGlobe, + faPalette, + faShieldHalved, +} from '@fortawesome/pro-duotone-svg-icons'; import { TopBar } from '@/components/layout/top-bar'; -import { apiClient } from '@/lib/api-client'; -import { notify } from '@/lib/toast'; -import { getInitials } from '@/lib/format'; +import { SectionCard } from '@/components/setup/section-card'; +import { + SETUP_STEP_NAMES, + SETUP_STEP_LABELS, + type SetupState, + type SetupStepName, + getSetupState, +} from '@/lib/setup-state'; -type WorkspaceMember = { - id: string; - name: { firstName: string; lastName: string } | null; - userEmail: string; - avatarUrl: string | null; - roles: { id: string; label: string }[]; +// Settings hub — the new /settings route. Replaces the old monolithic +// SettingsPage which had only the team listing. The team listing now lives +// at /settings/team via TeamSettingsPage. +// +// Each card links to a dedicated settings page. Pages built in earlier +// phases link to existing routes (branding, rules); pages coming in later +// phases link to placeholder routes that render "Coming soon" until those +// phases land. +// +// The completion status badges mirror the sidecar setup-state so an admin +// returning later sees what still needs attention. Sections without a +// matching wizard step (branding, widget, rules) don't show a badge. + +const STEP_TO_STATUS = (state: SetupState | null, step: SetupStepName | null) => { + if (!state || !step) return 'unknown' as const; + return state.steps[step].completed ? ('complete' as const) : ('incomplete' as const); }; export const SettingsPage = () => { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); + const [state, setState] = useState(null); useEffect(() => { - const fetchMembers = async () => { - try { - // Roles are only accessible via user JWT, not API key - const data = await apiClient.graphql( - `{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`, - undefined, - { silent: true }, - ); - const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? []; - // Roles come from the platform's role assignment — map known emails to roles - setMembers(rawMembers.map((m: any) => ({ - ...m, - roles: inferRoles(m.userEmail), - }))); - } catch { - // silently fail - } finally { - setLoading(false); - } - }; - fetchMembers(); + getSetupState() + .then(setState) + .catch(() => { + // Hub still works even if setup-state isn't reachable — just no badges. + }); }, []); - // Infer roles from email convention until platform roles API is accessible - const inferRoles = (email: string): { id: string; label: string }[] => { - if (email.includes('ramesh') || email.includes('admin')) return [{ id: 'mgr', label: 'HelixEngage Manager' }]; - if (email.includes('cc')) return [{ id: 'cc', label: 'HelixEngage User (CC Agent)' }]; - if (email.includes('marketing') || email.includes('sanjay')) return [{ id: 'exec', label: 'HelixEngage User (Executive)' }]; - if (email.includes('dr.')) return [{ id: 'doc', label: 'HelixEngage User (Doctor)' }]; - return [{ id: 'user', label: 'HelixEngage User' }]; - }; - - const [search, setSearch] = useState(''); - const [page, setPage] = useState(1); - const PAGE_SIZE = 10; - - const filtered = useMemo(() => { - if (!search.trim()) return members; - const q = search.toLowerCase(); - return members.filter((m) => { - const name = `${m.name?.firstName ?? ''} ${m.name?.lastName ?? ''}`.toLowerCase(); - return name.includes(q) || m.userEmail.toLowerCase().includes(q); - }); - }, [members, search]); - - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - const handleResetPassword = (member: WorkspaceMember) => { - notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`); - }; - return (
- + -
- {/* Employees section */} - - - { setSearch(e.target.value); setPage(1); }} - className="w-full rounded-lg border border-secondary bg-primary px-3 py-1.5 text-sm text-primary placeholder:text-placeholder outline-none focus:border-brand focus:ring-2 focus:ring-brand-100" - /> -
- } - /> - {loading ? ( -
-

Loading employees...

-
- ) : (<> - - - - - - - - - - {(member) => { - const firstName = member.name?.firstName ?? ''; - const lastName = member.name?.lastName ?? ''; - const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed'; - const initials = getInitials(firstName || '?', lastName || '?'); - const roles = member.roles?.map((r) => r.label) ?? []; +
+
+ {/* Identity & branding */} + + + - return ( - - -
- - {fullName} -
-
- - {member.userEmail} - - -
- {roles.length > 0 ? roles.map((role) => ( - - {role} - - )) : ( - No roles - )} -
-
- - - - Active - - - - - -
- ); - }} - -
- {totalPages > 1 && ( -
- - {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length} - -
- - -
-
- )} - )} - + {/* Care delivery */} + + + + + + + {/* Channels & automation */} + + + + + + + + {state && ( +

+ {SETUP_STEP_NAMES.filter(s => state.steps[s].completed).length} of{' '} + {SETUP_STEP_NAMES.length} setup steps complete. +

+ )} +
); }; + +const SectionGroup = ({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) => { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +}; diff --git a/src/pages/setup-wizard.tsx b/src/pages/setup-wizard.tsx new file mode 100644 index 0000000..9ccc12f --- /dev/null +++ b/src/pages/setup-wizard.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { WizardShell } from '@/components/setup/wizard-shell'; +import { WizardStep } from '@/components/setup/wizard-step'; +import { + SETUP_STEP_NAMES, + SETUP_STEP_LABELS, + type SetupState, + type SetupStepName, + getSetupState, + markSetupStepComplete, + dismissSetupWizard, +} from '@/lib/setup-state'; +import { notify } from '@/lib/toast'; +import { useAuth } from '@/providers/auth-provider'; + +// Top-level onboarding wizard. Auto-shown by login.tsx redirect when the +// workspace has incomplete setup steps. Each step renders a placeholder card +// in Phase 2 — Phase 5 swaps the placeholders for real form components from +// the matching settings pages (clinics, doctors, team, telephony, ai). +// +// The wizard is functional even at placeholder level: each step has a +// "Mark complete" button that calls PUT /api/config/setup-state/steps/, +// which lets the operator click through and verify first-run detection + +// dismiss flow without waiting for Phase 5. +export const SetupWizardPage = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const [state, setState] = useState(null); + const [activeStep, setActiveStep] = useState('identity'); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + getSetupState() + .then(s => { + if (cancelled) return; + setState(s); + // Land on the first incomplete step so the operator picks + // up where they left off. + const firstIncomplete = SETUP_STEP_NAMES.find(name => !s.steps[name].completed); + if (firstIncomplete) setActiveStep(firstIncomplete); + }) + .catch(err => { + console.error('Failed to load setup state', err); + notify.error('Setup', 'Could not load setup state. Please reload.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loading || !state) { + return ( +
+

Loading setup…

+
+ ); + } + + const activeIndex = SETUP_STEP_NAMES.indexOf(activeStep); + const isLastStep = activeIndex === SETUP_STEP_NAMES.length - 1; + const onPrev = activeIndex > 0 ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex - 1]) : null; + const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null; + + const handleMarkComplete = async () => { + setSaving(true); + try { + const updated = await markSetupStepComplete(activeStep, user?.email); + setState(updated); + notify.success('Step complete', SETUP_STEP_LABELS[activeStep].title); + // Auto-advance to next incomplete step (or stay if this was the last). + if (!isLastStep) { + setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]); + } + } catch { + notify.error('Setup', 'Could not save step status. Please try again.'); + } finally { + setSaving(false); + } + }; + + const handleFinish = () => { + notify.success('Setup complete', 'Welcome to your workspace!'); + navigate('/', { replace: true }); + }; + + const handleDismiss = async () => { + try { + await dismissSetupWizard(); + notify.success('Setup dismissed', 'You can finish setup later from Settings.'); + navigate('/', { replace: true }); + } catch { + notify.error('Setup', 'Could not dismiss the wizard. Please try again.'); + } + }; + + return ( + + + + + + ); +}; + +// Placeholder body for each step. Phase 5 will replace this dispatcher with +// real form components (clinic-form, doctor-form, invite-member-form, etc). +const StepPlaceholder = ({ step }: { step: SetupStepName }) => { + const meta = SETUP_STEP_LABELS[step]; + return ( +
+

Coming in Phase 5

+

+ The {meta.title.toLowerCase()} form will live here. For now, click Mark complete below + to test the wizard flow end-to-end. +

+
+ ); +}; diff --git a/src/pages/team-settings.tsx b/src/pages/team-settings.tsx new file mode 100644 index 0000000..11da2e8 --- /dev/null +++ b/src/pages/team-settings.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faKey, faToggleOn } from '@fortawesome/pro-duotone-svg-icons'; +import { Avatar } from '@/components/base/avatar/avatar'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table, TableCard } from '@/components/application/table/table'; +import { TopBar } from '@/components/layout/top-bar'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import { getInitials } from '@/lib/format'; + +// Workspace member listing — moved here from the old monolithic SettingsPage +// when /settings became the Settings hub. This page is mounted at /settings/team. +// +// Phase 2: read-only listing (same as before). +// Phase 3: invite form + role assignment editor will be added here. + +type WorkspaceMember = { + id: string; + name: { firstName: string; lastName: string } | null; + userEmail: string; + avatarUrl: string | null; + roles: { id: string; label: string }[]; +}; + +export const TeamSettingsPage = () => { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMembers = async () => { + try { + const data = await apiClient.graphql( + `{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`, + undefined, + { silent: true }, + ); + const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? []; + // Roles come from the platform's role assignment — Phase 3 wires the + // real getRoles query; for now infer from email convention. + setMembers(rawMembers.map((m: any) => ({ + ...m, + roles: inferRoles(m.userEmail), + }))); + } catch { + // silently fail + } finally { + setLoading(false); + } + }; + fetchMembers(); + }, []); + + const inferRoles = (email: string): { id: string; label: string }[] => { + if (email.includes('ramesh') || email.includes('admin')) return [{ id: 'mgr', label: 'HelixEngage Manager' }]; + if (email.includes('cc')) return [{ id: 'cc', label: 'HelixEngage User (CC Agent)' }]; + if (email.includes('marketing') || email.includes('sanjay')) return [{ id: 'exec', label: 'HelixEngage User (Executive)' }]; + if (email.includes('dr.')) return [{ id: 'doc', label: 'HelixEngage User (Doctor)' }]; + return [{ id: 'user', label: 'HelixEngage User' }]; + }; + + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const PAGE_SIZE = 10; + + const filtered = useMemo(() => { + if (!search.trim()) return members; + const q = search.toLowerCase(); + return members.filter((m) => { + const name = `${m.name?.firstName ?? ''} ${m.name?.lastName ?? ''}`.toLowerCase(); + return name.includes(q) || m.userEmail.toLowerCase().includes(q); + }); + }, [members, search]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const handleResetPassword = (member: WorkspaceMember) => { + notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`); + }; + + return ( +
+ + +
+ + + { setSearch(e.target.value); setPage(1); }} + className="w-full rounded-lg border border-secondary bg-primary px-3 py-1.5 text-sm text-primary placeholder:text-placeholder outline-none focus:border-brand focus:ring-2 focus:ring-brand-100" + /> +
+ } + /> + {loading ? ( +
+

Loading employees...

+
+ ) : (<> + + + + + + + + + + {(member) => { + const firstName = member.name?.firstName ?? ''; + const lastName = member.name?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed'; + const initials = getInitials(firstName || '?', lastName || '?'); + const roles = member.roles?.map((r) => r.label) ?? []; + + return ( + + +
+ + {fullName} +
+
+ + {member.userEmail} + + +
+ {roles.length > 0 ? roles.map((role) => ( + + {role} + + )) : ( + No roles + )} +
+
+ + + + Active + + + + + +
+ ); + }} +
+
+ {totalPages > 1 && ( +
+ + {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length} + +
+ + +
+
+ )} + )} + +
+ + ); +};