From f57fbc1f2485251b7a2a2c85a65ca3c3d714ac9e Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 10 Apr 2026 08:37:34 +0530 Subject: [PATCH] feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Setup wizard: 3-pane layout with right-side live previews, resume banner, edit/copy icons on team step, AI prompt configuration - Forms: employee-create replaces invite-member (no email invites), clinic form with address/hours/payment, doctor form with visit slots - Seed script: aligned to current SDK schema — doctors created as workspace members (HelixEngage Manager role), visitingHours replaced by doctorVisitSlot entity, clinics seeded, portalUserId linked dynamically, SUB/ORIGIN/GQL configurable via env vars - Pages: clinics + doctors CRUD updated for new schema, team settings with temp password + role assignment - New components: time-picker, day-selector, wizard-right-panes, wizard-layout-context, resume-setup-banner - Removed: invite-member-form (replaced by employee-create-form per no-email-invites rule) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-06-hospital-onboarding-self-service.md | 28 +- scripts/seed-data.ts | 227 +++++++-- .../application/date-picker/time-picker.tsx | 73 +++ .../application/day-selector/day-selector.tsx | 108 ++++ src/components/call-desk/appointment-form.tsx | 42 +- src/components/forms/clinic-form.tsx | 355 +++++++++++-- src/components/forms/doctor-form.tsx | 209 ++++++-- src/components/forms/employee-create-form.tsx | 205 ++++++++ src/components/forms/invite-member-form.tsx | 143 ------ src/components/layout/app-shell.tsx | 2 + src/components/setup/resume-setup-banner.tsx | 83 ++++ .../setup/wizard-layout-context.tsx | 27 + src/components/setup/wizard-right-panes.tsx | 426 ++++++++++++++++ src/components/setup/wizard-shell.tsx | 101 +++- src/components/setup/wizard-step-ai.tsx | 410 +++++++++++++-- src/components/setup/wizard-step-clinics.tsx | 141 +++++- src/components/setup/wizard-step-doctors.tsx | 91 +++- src/components/setup/wizard-step-identity.tsx | 2 + src/components/setup/wizard-step-team.tsx | 465 +++++++++++++++--- .../setup/wizard-step-telephony.tsx | 348 ++++++++++--- src/components/setup/wizard-step.tsx | 63 ++- src/pages/clinics.tsx | 227 +++++++-- src/pages/doctors.tsx | 194 ++++++-- src/pages/setup-wizard.tsx | 15 +- src/pages/team-settings.tsx | 182 +++---- 25 files changed, 3461 insertions(+), 706 deletions(-) create mode 100644 src/components/application/date-picker/time-picker.tsx create mode 100644 src/components/application/day-selector/day-selector.tsx create mode 100644 src/components/forms/employee-create-form.tsx delete mode 100644 src/components/forms/invite-member-form.tsx create mode 100644 src/components/setup/resume-setup-banner.tsx create mode 100644 src/components/setup/wizard-layout-context.tsx create mode 100644 src/components/setup/wizard-right-panes.tsx 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 index ef1268e..6320fee 100644 --- a/docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md +++ b/docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md @@ -42,8 +42,8 @@ Admin opens the workspace URL, signs in with the temp password. App detects an u 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` +4. **Team** — create supervisors and CC agents **in place** (name, email, temp password, role). If the role is `HelixEngage User` the form also shows a SIP seat dropdown so the admin links the new employee to an Agent profile in the same step. Posts to sidecar `POST /api/team/members` which chains `signUpInWorkspace` (using the workspace's own `inviteHash` server-side — no email is sent) → `updateWorkspaceMember` → `updateWorkspaceMemberRole` → optional `updateAgent`. **Never uses `sendInvitations`** — see `feedback-no-invites` memory for the absolute rule. +5. **Telephony** — read-only summary of which workspace members own which SIP seats. Seats themselves are seeded during onboarding (`onboard-hospital.sh` step 5b) and linked to members in step 4. Admin just confirms and advances. 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. @@ -54,7 +54,7 @@ Each setup page is also accessible standalone via the **Settings** menu. Admin c ### 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. +The admin hands each employee their email + temp password directly (WhatsApp, in-person, etc.). Employees sign in, land on the home dashboard, and change their password from their profile. They're already role-assigned and (if CC agents) SIP-linked from T1 step 4, so they see the right pages — and can take calls — immediately. --- @@ -110,13 +110,22 @@ Same pattern. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` stay in env (true secrets), } ``` -### 6. Member invitation stays email-based +### 6. Members are created in place — **never** via email invitation -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). +Absolute rule (see `feedback-no-invites` in memory): Helix Engage does not use the platform's `sendInvitations` flow for any reason, ever. Hospital admins are expected to onboard employees in person or over WhatsApp, hand out login credentials directly, and have the employee change the password on first login. + +The sidecar exposes `POST /api/team/members` taking `{ firstName, lastName, email, password, roleId, agentId? }`. Server-side it chains: + +1. `signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)` — the platform's `isPublicInviteLinkEnabled` + `inviteHash` values are read once per boot and used to authorize the create. The hash is a server-side secret, never surfaced to the admin UI, and no email is sent. +2. `updateWorkspaceMember` — set first name / last name (the signUp mutation doesn't take them). +3. `updateWorkspaceMemberRole` — assign the role the admin picked. +4. `updateAgent` (optional) — link the new workspace member to the chosen Agent profile if the admin selected a SIP seat. + +The Team wizard step and the `/settings/team` slideout both call this endpoint via the new `EmployeeCreateForm` component. The old `InviteMemberForm` and all `sendInvitations` call sites have been deleted. ### 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). +`HelixEngage Manager`, `HelixEngage Supervisor`, 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 form queries the platform via `getRoles` and uses real role IDs (no email-pattern hacks). The "is this person a CC agent, so show the SIP seat dropdown?" check matches by the exact label `HelixEngage User` — see `CC_AGENT_ROLE_LABEL` in `wizard-step-team.tsx` / `team-settings.tsx`. --- @@ -249,7 +258,7 @@ Each phase is a coherent commit. Don't ship phases out of order. - `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` +- `team-settings.tsx` becomes interactive — employees are created in place via the sidecar's `POST /api/team/members` endpoint (see architecture decision 6), real role dropdown via `getRoles`, role assignment via `updateWorkspaceMemberRole`. **Never uses `sendInvitations`.** **Verifies:** admin can create clinics, doctors, and invite team members from the staff portal without touching the database. @@ -312,9 +321,8 @@ Each wraps the corresponding form, adds wizard validation (required fields enfor - 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) +- Self-service password reset for employees (handled by the existing platform reset-password flow) - Onboarding analytics / metrics -- Email branding for invitation emails (uses platform default for now) --- @@ -326,7 +334,7 @@ Each wraps the corresponding form, adds wizard validation (required fields enfor 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. +4. **What if the admin tries to create an employee whose email already exists on the platform?** `signUpInWorkspace` will surface the platform's "email already exists" error, which the sidecar's `TeamService.extractGraphqlMessage` passes through to the toast. No "find or link existing user" path yet — if this comes up in practice, add a `findUserByEmail` preflight lookup before the `signUpInWorkspace` call. 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. diff --git a/scripts/seed-data.ts b/scripts/seed-data.ts index 5d360fa..a2f617f 100644 --- a/scripts/seed-data.ts +++ b/scripts/seed-data.ts @@ -1,23 +1,21 @@ /** * Helix Engage — Platform Data Seeder - * Creates 5 patient stories + 5 doctors with fully linked records. - * Run: cd helix-engage && npx tsx scripts/seed-data.ts + * Creates 2 clinics, 5 doctors with multi-clinic visit slots, + * 3 patient stories with fully linked records (campaigns, leads, + * calls, appointments, follow-ups, lead activities). * - * Platform field mapping (SDK name → platform name): - * Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions, - * clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated - * Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted, - * lastContactedAt→lastContacted, landingPageUrl→landingPage - * Call: callDirection→direction, durationSeconds→durationSec - * Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room - * FollowUp: followUpType→typeCustom, followUpStatus→status - * Patient: address→addressCustom - * Doctor: isActive→active, branch→branchClinic + * Run: cd helix-engage && npx tsx scripts/seed-data.ts + * Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain) + * + * Schema alignment (2026-04-10): + * - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity + * - Doctor.portalUserId omitted (workspace member IDs are per-deployment) + * - Clinic entity added (needed for visit slot FK) * NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed */ const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql'; -const SUB = 'fortytwo-dev'; +const SUB = process.env.SEED_SUB ?? 'fortytwo-dev'; const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010'; let token = ''; @@ -51,28 +49,119 @@ async function mk(entity: string, data: any): Promise { return d[`create${cap}`].id; } +// Create a workspace member (user account) and return its workspace member id. +// Uses signUpInWorkspace + updateWorkspaceMember for name + updateWorkspaceMemberRole. +// The invite hash and role IDs are fetched once and cached. +let _inviteHash = ''; +let _wsId = ''; +const _roleIds: Record = {}; + +async function ensureWorkspaceContext() { + if (_wsId) return; + const ws = await gql('{ currentWorkspace { id inviteHash } }'); + _wsId = ws.currentWorkspace.id; + _inviteHash = ws.currentWorkspace.inviteHash; + const roles = await gql('{ getRoles { id label } }'); + for (const r of roles.getRoles) _roleIds[r.label] = r.id; +} + +async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise { + await ensureWorkspaceContext(); + + // Create the user + link to workspace + await gql( + `mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) { + signUpInWorkspace(email: $email, password: $password, workspaceId: $workspaceId, workspaceInviteHash: $workspaceInviteHash) { workspace { id } } + }`, + { email, password, workspaceId: _wsId, workspaceInviteHash: _inviteHash }, + ); + + // Find the new member id + const members = await gql('{ workspaceMembers { edges { node { id userEmail } } } }'); + const member = members.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase()); + if (!member) throw new Error(`Could not find workspace member for ${email}`); + const memberId = member.node.id; + + // Set their display name + await gql( + `mutation($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`, + { id: memberId, data: { name: { firstName, lastName } } }, + ); + + // Assign role if specified + if (roleName && _roleIds[roleName]) { + await gql( + `mutation($wm: UUID!, $role: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $wm, roleId: $role) { id } }`, + { wm: memberId, role: _roleIds[roleName] }, + ); + } + + return memberId; +} + async function main() { console.log('🌱 Seeding Helix Engage demo data...\n'); await auth(); console.log('✅ Auth OK\n'); - // Workspace member IDs — switch based on target platform - const WM = GQL.includes('srv1477139') ? { - drSharma: '107efa70-fd32-4819-8936-994197c6ada1', - drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209', - drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c', - drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b', - drSingh: 'b2a00dd2-5bb5-4c29-8fb1-70a681193a4c', - } : { - drSharma: '251e9b32-3a83-4f3c-a904-fad7e8b840c3', - drPatel: '2b1bbf20-3838-434f-9fe9-b98436362230', - drKumar: '16109622-9b13-4682-b327-eb611ffa8338', - drReddy: '478a9ccb-d231-48fb-a740-0228d3c9325b', - drSingh: 'b854b55b-7302-4981-8dfc-bea516abdc86', - }; + // ═══════════════════════════════════════════ + // CLINICS (needed for doctor visit slots) + // ═══════════════════════════════════════════ + console.log('🏥 Clinics'); + const clinicKor = await mk('clinic', { + name: 'Global Hospital — Koramangala', + clinicName: 'Global Hospital — Koramangala', + status: 'ACTIVE', + opensAt: '08:00', closesAt: '20:00', + openMonday: true, openTuesday: true, openWednesday: true, + openThursday: true, openFriday: true, openSaturday: true, openSunday: false, + phone: { primaryPhoneNumber: '8041763265', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'Koramangala 4th Block' }, + onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES', + }); + console.log(` Koramangala: ${clinicKor}`); + + const clinicWf = await mk('clinic', { + name: 'Global Hospital — Whitefield', + clinicName: 'Global Hospital — Whitefield', + status: 'ACTIVE', + opensAt: '09:00', closesAt: '18:00', + openMonday: true, openTuesday: true, openWednesday: true, + openThursday: true, openFriday: true, openSaturday: true, openSunday: false, + phone: { primaryPhoneNumber: '8041763400', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, + addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'ITPL Main Road, Whitefield' }, + onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES', + }); + console.log(` Whitefield: ${clinicWf}\n`); + + await auth(); // ═══════════════════════════════════════════ - // DOCTORS (linked to workspace members) + // DOCTOR WORKSPACE MEMBERS + // + // Each doctor gets a real platform login so they can access the + // portal. Created via signUpInWorkspace, then linked to the Doctor + // entity via portalUserId. Email domain matches the deployment. + // ═══════════════════════════════════════════ + console.log('👤 Doctor workspace members (role: HelixEngage Manager)'); + const wmSharma = await mkMember('dr.sharma@globalcare.com', 'DrSharma@2026', 'Arun', 'Sharma', 'HelixEngage Manager'); + console.log(` Dr. Sharma member: ${wmSharma}`); + const wmPatel = await mkMember('dr.patel@globalcare.com', 'DrPatel@2026', 'Meena', 'Patel', 'HelixEngage Manager'); + console.log(` Dr. Patel member: ${wmPatel}`); + const wmKumar = await mkMember('dr.kumar@globalcare.com', 'DrKumar@2026', 'Rajesh', 'Kumar', 'HelixEngage Manager'); + console.log(` Dr. Kumar member: ${wmKumar}`); + const wmReddy = await mkMember('dr.reddy@globalcare.com', 'DrReddy@2026', 'Lakshmi', 'Reddy', 'HelixEngage Manager'); + console.log(` Dr. Reddy member: ${wmReddy}`); + const wmSingh = await mkMember('dr.singh@globalcare.com', 'DrSingh@2026', 'Harpreet', 'Singh', 'HelixEngage Manager'); + console.log(` Dr. Singh member: ${wmSingh}\n`); + + await auth(); + + // ═══════════════════════════════════════════ + // DOCTORS (linked to workspace members via portalUserId) + // + // visitingHours was removed — multi-clinic schedules now live + // on DoctorVisitSlot (seeded below). // ═══════════════════════════════════════════ console.log('👨‍⚕️ Doctors'); const drSharma = await mk('doctor', { @@ -82,16 +171,15 @@ async function main() { specialty: 'Interventional Cardiology', qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC', yearsOfExperience: 18, - visitingHours: 'Mon/Wed/Fri 10:00 AM – 1:00 PM', consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' }, phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, - email: { primaryEmail: 'dr.sharma@globalhospital.com' }, + email: { primaryEmail: 'dr.sharma@globalcare.com' }, registrationNumber: 'KMC-45672', active: true, - portalUserId: WM.drSharma, + portalUserId: wmSharma, }); - console.log(` Dr. Sharma (Cardiology, WM: ${WM.drSharma}): ${drSharma}`); + console.log(` Dr. Sharma (Cardiology → ${wmSharma}): ${drSharma}`); const drPatel = await mk('doctor', { name: 'Dr. Meena Patel', @@ -100,16 +188,15 @@ async function main() { specialty: 'Reproductive Medicine & IVF', qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine', yearsOfExperience: 15, - visitingHours: 'Tue/Thu/Sat 9:00 AM – 12:00 PM', consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, - email: { primaryEmail: 'dr.patel@globalhospital.com' }, + email: { primaryEmail: 'dr.patel@globalcare.com' }, registrationNumber: 'KMC-38291', active: true, - portalUserId: WM.drPatel, + portalUserId: wmPatel, }); - console.log(` Dr. Patel (Gynecology/IVF, WM: ${WM.drPatel}): ${drPatel}`); + console.log(` Dr. Patel (Gynecology/IVF → ${wmPatel}): ${drPatel}`); const drKumar = await mk('doctor', { name: 'Dr. Rajesh Kumar', @@ -118,16 +205,15 @@ async function main() { specialty: 'Joint Replacement & Sports Medicine', qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty', yearsOfExperience: 12, - visitingHours: 'Mon–Fri 2:00 PM – 5:00 PM', consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, - email: { primaryEmail: 'dr.kumar@globalhospital.com' }, + email: { primaryEmail: 'dr.kumar@globalcare.com' }, registrationNumber: 'KMC-51003', active: true, - portalUserId: WM.drKumar, + portalUserId: wmKumar, }); - console.log(` Dr. Kumar (Orthopedics, WM: ${WM.drKumar}): ${drKumar}`); + console.log(` Dr. Kumar (Orthopedics → ${wmKumar}): ${drKumar}`); const drReddy = await mk('doctor', { name: 'Dr. Lakshmi Reddy', @@ -136,16 +222,15 @@ async function main() { specialty: 'Internal Medicine & Preventive Health', qualifications: 'MBBS, MD (General Medicine)', yearsOfExperience: 20, - visitingHours: 'Mon–Sat 9:00 AM – 6:00 PM', consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' }, phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, - email: { primaryEmail: 'dr.reddy@globalhospital.com' }, + email: { primaryEmail: 'dr.reddy@globalcare.com' }, registrationNumber: 'KMC-22145', active: true, - portalUserId: WM.drReddy, + portalUserId: wmReddy, }); - console.log(` Dr. Reddy (General Medicine, WM: ${WM.drReddy}): ${drReddy}`); + console.log(` Dr. Reddy (General Medicine → ${wmReddy}): ${drReddy}`); const drSingh = await mk('doctor', { name: 'Dr. Harpreet Singh', @@ -154,16 +239,57 @@ async function main() { specialty: 'Otorhinolaryngology & Head/Neck Surgery', qualifications: 'MBBS, MS (ENT), DNB', yearsOfExperience: 10, - visitingHours: 'Mon/Wed/Fri 11:00 AM – 3:00 PM', consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, - email: { primaryEmail: 'dr.singh@globalhospital.com' }, + email: { primaryEmail: 'dr.singh@globalcare.com' }, registrationNumber: 'KMC-60782', active: true, - portalUserId: WM.drSingh, + portalUserId: wmSingh, }); - console.log(` Dr. Singh (ENT, WM: ${WM.drSingh}): ${drSingh}\n`); + console.log(` Dr. Singh (ENT → ${wmSingh}): ${drSingh}\n`); + + await auth(); + + // ═══════════════════════════════════════════ + // DOCTOR VISIT SLOTS (weekly schedule per doctor × clinic) + // ═══════════════════════════════════════════ + console.log('📅 Visit Slots'); + const slots: Array<{ doc: string; docName: string; clinic: string; clinicName: string; day: string; start: string; end: string }> = [ + // Dr. Sharma — Koramangala Mon/Wed/Fri 10:00–13:00 + { doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '10:00', end: '13:00' }, + { doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '10:00', end: '13:00' }, + { doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '10:00', end: '13:00' }, + // Dr. Patel — Whitefield Tue/Thu/Sat 9:00–12:00 + { doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '09:00', end: '12:00' }, + { doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '09:00', end: '12:00' }, + { doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '09:00', end: '12:00' }, + // Dr. Kumar — Koramangala Mon–Fri 14:00–17:00 + { doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '14:00', end: '17:00' }, + { doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'TUESDAY', start: '14:00', end: '17:00' }, + { doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '14:00', end: '17:00' }, + { doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'THURSDAY', start: '14:00', end: '17:00' }, + { doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '14:00', end: '17:00' }, + // Dr. Reddy — both clinics Mon–Sat + { doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '09:00', end: '13:00' }, + { doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '09:00', end: '13:00' }, + { doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '09:00', end: '13:00' }, + { doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '14:00', end: '18:00' }, + { doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '14:00', end: '18:00' }, + { doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '14:00', end: '18:00' }, + // Dr. Singh — Whitefield Mon/Wed/Fri 11:00–15:00 + { doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'MONDAY', start: '11:00', end: '15:00' }, + { doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'WEDNESDAY', start: '11:00', end: '15:00' }, + { doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'FRIDAY', start: '11:00', end: '15:00' }, + ]; + for (const s of slots) { + await mk('doctorVisitSlot', { + name: `Dr. ${s.docName} — ${s.day} ${s.start}–${s.end} (${s.clinicName})`, + doctorId: s.doc, clinicId: s.clinic, + dayOfWeek: s.day, startTime: s.start, endTime: s.end, + }); + } + console.log(` ${slots.length} visit slots created\n`); await auth(); @@ -406,9 +532,10 @@ async function main() { console.log(' Vijay — appointment reminder (tomorrow 9am)\n'); console.log('🎉 Seed complete!'); - console.log(' 5 doctors · 3 campaigns · 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups'); + console.log(' 2 clinics · 5 doctors · 20 visit slots · 3 campaigns'); + console.log(' 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups'); console.log(' Demo phones: Priya=9949879837, Ravi=6309248884'); - console.log(' All appointments linked to doctor entities'); + console.log(' Doctors linked to clinics via visit slots (multi-clinic schedule)'); } main().catch(e => { console.error('💥', e.message); process.exit(1); }); diff --git a/src/components/application/date-picker/time-picker.tsx b/src/components/application/date-picker/time-picker.tsx new file mode 100644 index 0000000..2eb3896 --- /dev/null +++ b/src/components/application/date-picker/time-picker.tsx @@ -0,0 +1,73 @@ +import { Select } from "@/components/base/select/select"; + +// 30-minute increments from 05:00 to 23:00 → 37 slots. +// Covers every realistic clinic opening / closing time. +// Values are 24-hour HH:MM strings — the same format stored on the +// Clinic + DoctorVisitSlot entities in the platform. Labels are +// 12-hour format with AM/PM for readability. +const TIME_SLOTS = Array.from({ length: 37 }, (_, i) => { + const totalMinutes = 5 * 60 + i * 30; + const hour = Math.floor(totalMinutes / 60); + const minute = totalMinutes % 60; + const h12 = hour % 12 || 12; + const period = hour >= 12 ? "PM" : "AM"; + const id = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + const label = `${h12}:${String(minute).padStart(2, "0")} ${period}`; + return { id, label }; +}); + +type TimePickerProps = { + /** Field label rendered above the select. */ + label?: string; + /** Current value in 24-hour HH:MM format, or null when unset. */ + value: string | null; + /** Called with the new HH:MM string when the user picks a slot. */ + onChange: (value: string) => void; + isRequired?: boolean; + isDisabled?: boolean; + placeholder?: string; +}; + +// A minimal time-of-day picker built on top of the existing base +// Select component. Intentionally dropdown-based rather than the +// full DateTimePicker popover pattern from the reference demo — +// the clinic + doctor flows only need time, not date, and a +// dropdown is faster to use when the agent already knows the time. +// +// Use this for: clinic.opensAt / closesAt, doctorVisitSlot.startTime / +// endTime. For time-AND-date (appointment scheduling), stick with the +// existing DatePicker in the same directory. +export const TimePicker = ({ + label, + value, + onChange, + isRequired, + isDisabled, + placeholder = "Select time", +}: TimePickerProps) => ( + +); + +// Format a 24-hour HH:MM string as a 12-hour display label (e.g. +// "09:30" → "9:30 AM"). Useful on list/detail pages that render +// stored clinic hours without re-mounting the picker. +export const formatTimeLabel = (hhmm: string | null | undefined): string => { + if (!hhmm) return "—"; + const [h, m] = hhmm.split(":").map(Number); + if (Number.isNaN(h) || Number.isNaN(m)) return hhmm; + const h12 = h % 12 || 12; + const period = h >= 12 ? "PM" : "AM"; + return `${h12}:${String(m).padStart(2, "0")} ${period}`; +}; diff --git a/src/components/application/day-selector/day-selector.tsx b/src/components/application/day-selector/day-selector.tsx new file mode 100644 index 0000000..5a26fba --- /dev/null +++ b/src/components/application/day-selector/day-selector.tsx @@ -0,0 +1,108 @@ +import { cx } from "@/utils/cx"; + +// Keys match the Clinic entity's openMonday..openSunday fields +// directly — no translation layer needed when reading/writing the +// form value into GraphQL mutations. +export type DayKey = + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday" + | "sunday"; + +export type DaySelection = Record; + +const DAYS: { key: DayKey; label: string }[] = [ + { key: "monday", label: "Mon" }, + { key: "tuesday", label: "Tue" }, + { key: "wednesday", label: "Wed" }, + { key: "thursday", label: "Thu" }, + { key: "friday", label: "Fri" }, + { key: "saturday", label: "Sat" }, + { key: "sunday", label: "Sun" }, +]; + +type DaySelectorProps = { + /** Selected-state for each weekday. */ + value: DaySelection; + /** Fires with the full updated selection whenever a pill is tapped. */ + onChange: (value: DaySelection) => void; + /** Optional heading above the pills. */ + label?: string; + /** Optional helper text below the pills. */ + hint?: string; +}; + +// Seven tappable Mon–Sun pills. Used on the Clinic form to pick which +// days the clinic is open, since the Clinic entity has seven separate +// BOOLEAN fields (openMonday..openSunday) — SDK has no MULTI_SELECT. +// Also reusable anywhere else we need a weekly-recurrence picker +// (future: follow-up schedules, on-call rotations). +export const DaySelector = ({ value, onChange, label, hint }: DaySelectorProps) => ( +
+ {label && ( + {label} + )} +
+ {DAYS.map(({ key, label: dayLabel }) => { + const isSelected = !!value[key]; + return ( + + ); + })} +
+ {hint && {hint}} +
+); + +// Helper factories — use these instead of spelling out the empty +// object literal everywhere. +export const emptyDaySelection = (): DaySelection => ({ + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + sunday: false, +}); + +// The default new-clinic state: Mon–Sat open, Sun closed. Matches the +// typical Indian outpatient hospital schedule. +export const defaultDaySelection = (): DaySelection => ({ + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + sunday: false, +}); + +// Format a DaySelection as a compact human-readable string for list +// pages (e.g. "Mon–Fri", "Mon–Sat", "Mon Wed Fri"). Collapses +// consecutive selected days into ranges. +export const formatDaySelection = (sel: DaySelection): string => { + const openKeys = DAYS.filter((d) => sel[d.key]).map((d) => d.label); + if (openKeys.length === 0) return "Closed"; + if (openKeys.length === 7) return "Every day"; + // Monday-Friday, Monday-Saturday shorthand + if (openKeys.length === 5 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri") return "Mon–Fri"; + if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "Mon–Sat"; + return openKeys.join(" "); +}; diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index 035dd8e..e2cad43 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -121,22 +121,44 @@ export const AppointmentForm = ({ const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - // Fetch doctors on mount + // Fetch doctors on mount. Doctors are hospital-wide — no single + // `clinic` field anymore. We pull the full visit-slot list via the + // doctorVisitSlots reverse relation so the agent can see which + // clinics + days this doctor covers in the picker. useEffect(() => { if (!isOpen) return; apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>( `{ doctors(first: 50) { edges { node { - id name fullName { firstName lastName } department clinic { id name clinicName } + id name fullName { firstName lastName } department + doctorVisitSlots(first: 50) { + edges { node { id clinic { id clinicName } dayOfWeek startTime endTime } } + } } } } }`, ).then(data => { - const docs = data.doctors.edges.map(e => ({ - id: e.node.id, - name: e.node.fullName - ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() - : e.node.name, - department: e.node.department ?? '', - clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '', - })); + const docs = data.doctors.edges.map(e => { + // Flatten the visit-slot list into a comma-separated + // clinic summary for display. Keep full slot data on + // the record in case future UX needs it (e.g., show + // only slots matching the selected date's weekday). + const slotEdges: Array<{ node: any }> = e.node.doctorVisitSlots?.edges ?? []; + const clinicNames = Array.from( + new Set( + slotEdges + .map((se) => se.node.clinic?.clinicName) + .filter((n): n is string => !!n), + ), + ); + return { + id: e.node.id, + name: e.node.fullName + ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() + : e.node.name, + department: e.node.department ?? '', + // `clinic` here is a display-only summary: "Koramangala, Whitefield" + // or empty if the doctor has no slots yet. + clinic: clinicNames.join(', '), + }; + }); setDoctors(docs); }).catch(() => {}); }, [isOpen]); diff --git a/src/components/forms/clinic-form.tsx b/src/components/forms/clinic-form.tsx index b928481..eaba71d 100644 --- a/src/components/forms/clinic-form.tsx +++ b/src/components/forms/clinic-form.tsx @@ -1,21 +1,92 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons'; +import { parseDate, getLocalTimeZone, today } from '@internationalized/date'; +import type { DateValue } from 'react-aria-components'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; -import { TextArea } from '@/components/base/textarea/textarea'; import { Toggle } from '@/components/base/toggle/toggle'; +import { Button } from '@/components/base/buttons/button'; +import { DatePicker } from '@/components/application/date-picker/date-picker'; +import { TimePicker } from '@/components/application/date-picker/time-picker'; +import { + DaySelector, + defaultDaySelection, + type DaySelection, +} from '@/components/application/day-selector/day-selector'; -// Reusable clinic form used by both the /settings/clinics slideout and the -// /setup wizard step. The parent owns the form state so it can also own the -// submit button and the loading/error UI. +// Reusable clinic form used by /settings/clinics slideout and the /setup +// wizard step. The parent owns form state + the save flow so it can decide +// how to orchestrate the multi-step create chain (one createClinic, then one +// createHoliday per holiday, then one createClinicRequiredDocument per doc). // -// Field shapes mirror the platform's ClinicCreateInput (which uses -// `addressCustom`, not `address`, and `onlineBooking`, not -// `onlineBookingEnabled` as the SDK field). See -// FortyTwoApps/helix-engage/src/objects/clinic.object.ts for the SDK source of -// truth; GraphQL-level differences are normalised in clinicFormToGraphQLInput. +// Schema (matches the Clinic entity in +// FortyTwoApps/helix-engage/src/objects/clinic.object.ts, column names +// derived from SDK labels — that's why opensAt/closesAt and not openTime/ +// closeTime): +// - clinicName (TEXT) +// - address (ADDRESS → addressCustomAddress*) +// - phone (PHONES) +// - email (EMAILS) +// - openMonday..openSunday (7 BOOLEANs) +// - opensAt / closesAt (TEXT, HH:MM) +// - status (SELECT enum) +// - walkInAllowed / onlineBooking (BOOLEAN) +// - cancellationWindowHours / arriveEarlyMin (NUMBER) +// +// Plus two child entities populated separately: +// - Holiday (one record per closure date) +// - ClinicRequiredDocument (one record per required doc type) export type ClinicStatus = 'ACTIVE' | 'TEMPORARILY_CLOSED' | 'PERMANENTLY_CLOSED'; +// Matches the SELECT enum on ClinicRequiredDocument. Keep in sync with +// FortyTwoApps/helix-engage/src/objects/clinic-required-document.object.ts. +export type DocumentType = + | 'ID_PROOF' + | 'AADHAAR' + | 'PAN' + | 'REFERRAL_LETTER' + | 'PRESCRIPTION' + | 'INSURANCE_CARD' + | 'PREVIOUS_REPORTS' + | 'PHOTO' + | 'OTHER'; + +const DOCUMENT_TYPE_LABELS: Record = { + ID_PROOF: 'Government ID', + AADHAAR: 'Aadhaar Card', + PAN: 'PAN Card', + REFERRAL_LETTER: 'Referral Letter', + PRESCRIPTION: 'Prescription', + INSURANCE_CARD: 'Insurance Card', + PREVIOUS_REPORTS: 'Previous Reports', + PHOTO: 'Passport Photo', + OTHER: 'Other', +}; + +const DOCUMENT_TYPE_ORDER: DocumentType[] = [ + 'ID_PROOF', + 'AADHAAR', + 'PAN', + 'REFERRAL_LETTER', + 'PRESCRIPTION', + 'INSURANCE_CARD', + 'PREVIOUS_REPORTS', + 'PHOTO', + 'OTHER', +]; + +export type ClinicHolidayEntry = { + // Populated on the existing record when editing; undefined for freshly + // added holidays the user hasn't saved yet. Used by the parent to + // decide create vs update vs delete on save. + id?: string; + date: string; // ISO yyyy-MM-dd + label: string; +}; + export type ClinicFormValues = { + // Core clinic fields clinicName: string; addressStreet1: string; addressStreet2: string; @@ -24,15 +95,19 @@ export type ClinicFormValues = { addressPostcode: string; phone: string; email: string; - weekdayHours: string; - saturdayHours: string; - sundayHours: string; + // Schedule — simple pattern + openDays: DaySelection; + opensAt: string | null; + closesAt: string | null; + // Status + booking policy status: ClinicStatus; walkInAllowed: boolean; onlineBooking: boolean; cancellationWindowHours: string; arriveEarlyMin: string; - requiredDocuments: string; + // Children (persisted via separate mutations) + requiredDocumentTypes: DocumentType[]; + holidays: ClinicHolidayEntry[]; }; export const emptyClinicFormValues = (): ClinicFormValues => ({ @@ -44,15 +119,16 @@ export const emptyClinicFormValues = (): ClinicFormValues => ({ addressPostcode: '', phone: '', email: '', - weekdayHours: '9:00 AM - 6:00 PM', - saturdayHours: '9:00 AM - 2:00 PM', - sundayHours: 'Closed', + openDays: defaultDaySelection(), + opensAt: '09:00', + closesAt: '18:00', status: 'ACTIVE', walkInAllowed: true, onlineBooking: true, cancellationWindowHours: '24', arriveEarlyMin: '15', - requiredDocuments: '', + requiredDocumentTypes: [], + holidays: [], }); const STATUS_ITEMS = [ @@ -61,18 +137,32 @@ const STATUS_ITEMS = [ { id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' }, ]; -// Convert form state into the shape the platform's createClinic / updateClinic -// mutations expect. Only non-empty fields are included so the platform can -// apply its own defaults for the rest. -export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record => { +// Build the payload for `createClinic` / `updateClinic`. Holidays and +// required-documents are NOT included here — they're child records with +// their own mutations, orchestrated by the parent component after the +// clinic itself has been created and its id is known. +export const clinicCoreToGraphQLInput = (v: ClinicFormValues): Record => { const input: Record = { clinicName: v.clinicName.trim(), status: v.status, walkInAllowed: v.walkInAllowed, onlineBooking: v.onlineBooking, + openMonday: v.openDays.monday, + openTuesday: v.openDays.tuesday, + openWednesday: v.openDays.wednesday, + openThursday: v.openDays.thursday, + openFriday: v.openDays.friday, + openSaturday: v.openDays.saturday, + openSunday: v.openDays.sunday, }; - const hasAddress = v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode; + // Column names on the platform come from the SDK `label`, not + // `name`. "Opens At" → opensAt, "Closes At" → closesAt. + if (v.opensAt) input.opensAt = v.opensAt; + if (v.closesAt) input.closesAt = v.closesAt; + + const hasAddress = + v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode; if (hasAddress) { input.addressCustom = { addressStreet1: v.addressStreet1 || null, @@ -100,9 +190,6 @@ export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record> => + v.holidays.map((h) => ({ + date: h.date, + reasonLabel: h.label.trim() || null, // column name matches the SDK label "Reason / Label" + clinicId, + })); + +// Helper: build ClinicRequiredDocumentCreateInput payloads. One per +// selected document type. +export const requiredDocInputsFromForm = ( + v: ClinicFormValues, + clinicId: string, +): Array> => + v.requiredDocumentTypes.map((t) => ({ + documentType: t, + clinicId, + })); + type ClinicFormProps = { value: ClinicFormValues; onChange: (value: ClinicFormValues) => void; @@ -124,6 +233,42 @@ type ClinicFormProps = { export const ClinicForm = ({ value, onChange }: ClinicFormProps) => { const patch = (updates: Partial) => onChange({ ...value, ...updates }); + // Required-docs add/remove handlers. The user picks a type from the + // dropdown; it gets added to the list; the pill row below shows + // selected types with an X to remove. Dropdown filters out + // already-selected types so the user can't pick duplicates. + const availableDocTypes = DOCUMENT_TYPE_ORDER.filter( + (t) => !value.requiredDocumentTypes.includes(t), + ).map((t) => ({ id: t, label: DOCUMENT_TYPE_LABELS[t] })); + + const addDocType = (type: DocumentType) => { + if (value.requiredDocumentTypes.includes(type)) return; + patch({ requiredDocumentTypes: [...value.requiredDocumentTypes, type] }); + }; + + const removeDocType = (type: DocumentType) => { + patch({ + requiredDocumentTypes: value.requiredDocumentTypes.filter((t) => t !== type), + }); + }; + + // Holiday add/remove handlers. Freshly-added entries have no `id` + // field; the parent's save flow treats those as "create". + const addHoliday = () => { + const todayIso = today(getLocalTimeZone()).toString(); + patch({ holidays: [...value.holidays, { date: todayIso, label: '' }] }); + }; + + const updateHoliday = (index: number, updates: Partial) => { + const next = [...value.holidays]; + next[index] = { ...next[index], ...updates }; + patch({ holidays: next }); + }; + + const removeHoliday = (index: number) => { + patch({ holidays: value.holidays.filter((_, i) => i !== index) }); + }; + return (
{ {(item) => } + {/* Address */}
-

Address

+

+ Address +

{ />
+ {/* Contact */}
-

Contact

+

+ Contact +

{ />
+ {/* Visiting hours — day pills + single time range */}
-

Visiting hours

- patch({ weekdayHours: v })} +

+ Visiting hours +

+ patch({ openDays })} />
- patch({ saturdayHours: v })} + patch({ opensAt })} /> - patch({ sundayHours: v })} + patch({ closesAt })} />
+ {/* Holiday closures */}
-

Booking policy

+

+ Holiday closures (optional) +

+ {value.holidays.length === 0 && ( +

+ No holidays configured. Add dates when this clinic is closed (Diwali, + Republic Day, maintenance days, etc.). +

+ )} + {value.holidays.map((h, idx) => ( +
+
+ + Date + + + updateHoliday(idx, { date: dv ? dv.toString() : '' }) + } + /> +
+
+ updateHoliday(idx, { label })} + /> +
+ +
+ ))} + +
+ + {/* Booking policy */} +
+

+ Booking policy +

{ onChange={(v) => patch({ arriveEarlyMin: v })} />
-