mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, string> = {};
|
||||
|
||||
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<string> {
|
||||
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); });
|
||||
|
||||
73
src/components/application/date-picker/time-picker.tsx
Normal file
73
src/components/application/date-picker/time-picker.tsx
Normal file
@@ -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) => (
|
||||
<Select
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
items={TIME_SLOTS}
|
||||
selectedKey={value}
|
||||
onSelectionChange={(key) => {
|
||||
if (key !== null) onChange(String(key));
|
||||
}}
|
||||
isRequired={isRequired}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{(slot) => <Select.Item id={slot.id} label={slot.label} />}
|
||||
</Select>
|
||||
);
|
||||
|
||||
// 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}`;
|
||||
};
|
||||
108
src/components/application/day-selector/day-selector.tsx
Normal file
108
src/components/application/day-selector/day-selector.tsx
Normal file
@@ -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<DayKey, boolean>;
|
||||
|
||||
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) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-secondary">{label}</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(({ key, label: dayLabel }) => {
|
||||
const isSelected = !!value[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...value, [key]: !isSelected })}
|
||||
className={cx(
|
||||
"flex h-10 min-w-12 items-center justify-center rounded-full border px-4 text-sm font-semibold transition duration-100 ease-linear",
|
||||
isSelected
|
||||
? "border-brand bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||
: "border-secondary bg-primary text-secondary hover:border-primary hover:bg-secondary_hover",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{dayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hint && <span className="text-xs text-tertiary">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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(" ");
|
||||
};
|
||||
@@ -121,22 +121,44 @@ export const AppointmentForm = ({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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]);
|
||||
|
||||
@@ -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<DocumentType, string> = {
|
||||
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<string, unknown> => {
|
||||
// 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<string, unknown> => {
|
||||
const input: Record<string, unknown> = {
|
||||
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<string, un
|
||||
};
|
||||
}
|
||||
|
||||
if (v.weekdayHours.trim()) input.weekdayHours = v.weekdayHours.trim();
|
||||
if (v.saturdayHours.trim()) input.saturdayHours = v.saturdayHours.trim();
|
||||
if (v.sundayHours.trim()) input.sundayHours = v.sundayHours.trim();
|
||||
if (v.cancellationWindowHours.trim()) {
|
||||
const n = Number(v.cancellationWindowHours);
|
||||
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
|
||||
@@ -111,11 +198,33 @@ export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record<string, un
|
||||
const n = Number(v.arriveEarlyMin);
|
||||
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
|
||||
}
|
||||
if (v.requiredDocuments.trim()) input.requiredDocuments = v.requiredDocuments.trim();
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
// Helper: build HolidayCreateInput payloads. Use after the clinic has
|
||||
// been created and its id is known.
|
||||
export const holidayInputsFromForm = (
|
||||
v: ClinicFormValues,
|
||||
clinicId: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
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<Record<string, unknown>> =>
|
||||
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<ClinicFormValues>) => 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<ClinicHolidayEntry>) => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
@@ -144,8 +289,11 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
{/* Address */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Address</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Address
|
||||
</p>
|
||||
<Input
|
||||
label="Street address"
|
||||
placeholder="Street / building / landmark"
|
||||
@@ -180,8 +328,11 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Contact
|
||||
</p>
|
||||
<Input
|
||||
label="Phone"
|
||||
type="tel"
|
||||
@@ -198,32 +349,96 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visiting hours — day pills + single time range */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Visiting hours</p>
|
||||
<Input
|
||||
label="Weekdays"
|
||||
placeholder="9:00 AM - 6:00 PM"
|
||||
value={value.weekdayHours}
|
||||
onChange={(v) => patch({ weekdayHours: v })}
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Visiting hours
|
||||
</p>
|
||||
<DaySelector
|
||||
label="Open days"
|
||||
hint="Pick the days this clinic is open. The time range below applies to every selected day."
|
||||
value={value.openDays}
|
||||
onChange={(openDays) => patch({ openDays })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Saturday"
|
||||
placeholder="9:00 AM - 2:00 PM"
|
||||
value={value.saturdayHours}
|
||||
onChange={(v) => patch({ saturdayHours: v })}
|
||||
<TimePicker
|
||||
label="Opens at"
|
||||
value={value.opensAt}
|
||||
onChange={(opensAt) => patch({ opensAt })}
|
||||
/>
|
||||
<Input
|
||||
label="Sunday"
|
||||
placeholder="Closed"
|
||||
value={value.sundayHours}
|
||||
onChange={(v) => patch({ sundayHours: v })}
|
||||
<TimePicker
|
||||
label="Closes at"
|
||||
value={value.closesAt}
|
||||
onChange={(closesAt) => patch({ closesAt })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holiday closures */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Booking policy</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Holiday closures (optional)
|
||||
</p>
|
||||
{value.holidays.length === 0 && (
|
||||
<p className="text-xs text-tertiary">
|
||||
No holidays configured. Add dates when this clinic is closed (Diwali,
|
||||
Republic Day, maintenance days, etc.).
|
||||
</p>
|
||||
)}
|
||||
{value.holidays.map((h, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-end gap-2 rounded-lg border border-secondary bg-secondary p-3"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<span className="mb-1 block text-xs font-medium text-secondary">
|
||||
Date
|
||||
</span>
|
||||
<DatePicker
|
||||
value={h.date ? parseDate(h.date) : null}
|
||||
onChange={(dv: DateValue | null) =>
|
||||
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Reason"
|
||||
placeholder="e.g. Diwali"
|
||||
value={h.label}
|
||||
onChange={(label) => updateHoliday(idx, { label })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary-destructive"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||
)}
|
||||
onClick={() => removeHoliday(idx)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||
)}
|
||||
onClick={addHoliday}
|
||||
className="self-start"
|
||||
>
|
||||
Add holiday
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Booking policy */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Booking policy
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-4">
|
||||
<Toggle
|
||||
label="Walk-ins allowed"
|
||||
@@ -250,13 +465,49 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
onChange={(v) => patch({ arriveEarlyMin: v })}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Required documents"
|
||||
placeholder="ID proof, referral letter, previous reports..."
|
||||
value={value.requiredDocuments}
|
||||
onChange={(v) => patch({ requiredDocuments: v })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required documents — multi-select → pills */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Required documents (optional)
|
||||
</p>
|
||||
{availableDocTypes.length > 0 && (
|
||||
<Select
|
||||
label="Add a required document"
|
||||
placeholder="Pick a document type..."
|
||||
items={availableDocTypes}
|
||||
selectedKey={null}
|
||||
onSelectionChange={(key) => {
|
||||
if (key) addDocType(key as DocumentType);
|
||||
}}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
)}
|
||||
{value.requiredDocumentTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.requiredDocumentTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => removeDocType(t)}
|
||||
className="group flex items-center gap-2 rounded-full border border-brand bg-brand-secondary px-3 py-1.5 text-sm font-medium text-brand-secondary transition hover:bg-brand-primary_hover"
|
||||
>
|
||||
{DOCUMENT_TYPE_LABELS[t]}
|
||||
<FontAwesomeIcon
|
||||
icon={faTrash}
|
||||
className="size-3 text-fg-quaternary group-hover:text-fg-error-primary"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{value.requiredDocumentTypes.length === 0 && (
|
||||
<p className="text-xs text-tertiary">
|
||||
No required documents. Patients won't be asked to bring anything.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
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 { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||
|
||||
// Reusable doctor form used by /settings/doctors and the /setup wizard. The
|
||||
// parent owns both the form state and the list of clinics to populate the
|
||||
// clinic dropdown (since the list page will already have it loaded).
|
||||
// Doctor form — hospital-wide profile with multi-clinic, multi-day
|
||||
// visiting schedule. Each row in the "visiting schedule" section maps
|
||||
// to one DoctorVisitSlot child record. The parent component owns the
|
||||
// mutation orchestration (create doctor, then create each slot).
|
||||
//
|
||||
// Field names mirror the platform's DoctorCreateInput — notably `active`, not
|
||||
// `isActive`, and `clinicId` for the relation.
|
||||
// Previously the form had a single `clinicId` dropdown + a free-text
|
||||
// `visitingHours` textarea. Both dropped — doctors are now hospital-
|
||||
// wide, and their presence at each clinic is expressed via the
|
||||
// DoctorVisitSlot records.
|
||||
|
||||
export type DoctorDepartment =
|
||||
| 'CARDIOLOGY'
|
||||
@@ -20,6 +26,27 @@ export type DoctorDepartment =
|
||||
| 'PEDIATRICS'
|
||||
| 'ONCOLOGY';
|
||||
|
||||
// Matches the DoctorVisitSlot.dayOfWeek SELECT enum on the SDK entity.
|
||||
export type DayOfWeek =
|
||||
| 'MONDAY'
|
||||
| 'TUESDAY'
|
||||
| 'WEDNESDAY'
|
||||
| 'THURSDAY'
|
||||
| 'FRIDAY'
|
||||
| 'SATURDAY'
|
||||
| 'SUNDAY';
|
||||
|
||||
export type DoctorVisitSlotEntry = {
|
||||
// Populated on existing records when editing; undefined for
|
||||
// freshly-added rows. Used by the parent to decide create vs
|
||||
// update vs delete on save.
|
||||
id?: string;
|
||||
clinicId: string;
|
||||
dayOfWeek: DayOfWeek | '';
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
};
|
||||
|
||||
export type DoctorFormValues = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -27,14 +54,14 @@ export type DoctorFormValues = {
|
||||
specialty: string;
|
||||
qualifications: string;
|
||||
yearsOfExperience: string;
|
||||
clinicId: string;
|
||||
visitingHours: string;
|
||||
consultationFeeNew: string;
|
||||
consultationFeeFollowUp: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
registrationNumber: string;
|
||||
active: boolean;
|
||||
// Multi-clinic, multi-day visiting schedule. One entry per slot.
|
||||
visitSlots: DoctorVisitSlotEntry[];
|
||||
};
|
||||
|
||||
export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
||||
@@ -44,14 +71,13 @@ export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
||||
specialty: '',
|
||||
qualifications: '',
|
||||
yearsOfExperience: '',
|
||||
clinicId: '',
|
||||
visitingHours: '',
|
||||
consultationFeeNew: '',
|
||||
consultationFeeFollowUp: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
registrationNumber: '',
|
||||
active: true,
|
||||
visitSlots: [],
|
||||
});
|
||||
|
||||
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||
@@ -65,10 +91,20 @@ const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||
{ id: 'ONCOLOGY', label: 'Oncology' },
|
||||
];
|
||||
|
||||
// Convert form state into the shape createDoctor/updateDoctor mutations
|
||||
// expect. yearsOfExperience and consultation fees are text fields in the UI
|
||||
// but typed in GraphQL, so we parse + validate here.
|
||||
export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
||||
const DAY_ITEMS: { id: DayOfWeek; label: string }[] = [
|
||||
{ id: 'MONDAY', label: 'Monday' },
|
||||
{ id: 'TUESDAY', label: 'Tuesday' },
|
||||
{ id: 'WEDNESDAY', label: 'Wednesday' },
|
||||
{ id: 'THURSDAY', label: 'Thursday' },
|
||||
{ id: 'FRIDAY', label: 'Friday' },
|
||||
{ id: 'SATURDAY', label: 'Saturday' },
|
||||
{ id: 'SUNDAY', label: 'Sunday' },
|
||||
];
|
||||
|
||||
// Build the createDoctor / updateDoctor mutation payload. Visit slots
|
||||
// are persisted via a separate mutation chain — see the parent
|
||||
// component's handleSave.
|
||||
export const doctorCoreToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
||||
const input: Record<string, unknown> = {
|
||||
fullName: {
|
||||
firstName: v.firstName.trim(),
|
||||
@@ -84,8 +120,6 @@ export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, un
|
||||
const n = Number(v.yearsOfExperience);
|
||||
if (!Number.isNaN(n)) input.yearsOfExperience = n;
|
||||
}
|
||||
if (v.clinicId) input.clinicId = v.clinicId;
|
||||
if (v.visitingHours.trim()) input.visitingHours = v.visitingHours.trim();
|
||||
if (v.consultationFeeNew.trim()) {
|
||||
const n = Number(v.consultationFeeNew);
|
||||
if (!Number.isNaN(n)) {
|
||||
@@ -123,6 +157,23 @@ export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, un
|
||||
return input;
|
||||
};
|
||||
|
||||
// Build one DoctorVisitSlotCreateInput per complete slot. Drops any
|
||||
// half-filled rows silently — the form can't validate mid-entry
|
||||
// without blocking the user.
|
||||
export const visitSlotInputsFromForm = (
|
||||
v: DoctorFormValues,
|
||||
doctorId: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
v.visitSlots
|
||||
.filter((s) => s.clinicId && s.dayOfWeek && s.startTime && s.endTime)
|
||||
.map((s) => ({
|
||||
doctorId,
|
||||
clinicId: s.clinicId,
|
||||
dayOfWeek: s.dayOfWeek,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
}));
|
||||
|
||||
type ClinicOption = { id: string; label: string };
|
||||
|
||||
type DoctorFormProps = {
|
||||
@@ -134,6 +185,26 @@ type DoctorFormProps = {
|
||||
export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
||||
const patch = (updates: Partial<DoctorFormValues>) => onChange({ ...value, ...updates });
|
||||
|
||||
// Visit-slot handlers — add/edit/remove inline inside the form.
|
||||
const addSlot = () => {
|
||||
patch({
|
||||
visitSlots: [
|
||||
...value.visitSlots,
|
||||
{ clinicId: clinics[0]?.id ?? '', dayOfWeek: '', startTime: '09:00', endTime: '13:00' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const updateSlot = (index: number, updates: Partial<DoctorVisitSlotEntry>) => {
|
||||
const next = [...value.visitSlots];
|
||||
next[index] = { ...next[index], ...updates };
|
||||
patch({ visitSlots: next });
|
||||
};
|
||||
|
||||
const removeSlot = (index: number) => {
|
||||
patch({ visitSlots: value.visitSlots.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -185,24 +256,94 @@ export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Clinic"
|
||||
placeholder={clinics.length === 0 ? 'Add a clinic first' : 'Assign to a clinic'}
|
||||
isDisabled={clinics.length === 0}
|
||||
items={clinics}
|
||||
selectedKey={value.clinicId || null}
|
||||
onSelectionChange={(key) => patch({ clinicId: (key as string) || '' })}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<TextArea
|
||||
label="Visiting hours"
|
||||
placeholder="Mon–Fri 10 AM – 2 PM, Sat 10 AM – 12 PM"
|
||||
value={value.visitingHours}
|
||||
onChange={(v) => patch({ visitingHours: v })}
|
||||
rows={2}
|
||||
/>
|
||||
{/* Visiting schedule — one row per clinic/day slot */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Visiting schedule
|
||||
</p>
|
||||
{clinics.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
|
||||
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
You need at least one clinic before you can schedule doctor visits.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{value.visitSlots.length === 0 && (
|
||||
<p className="text-xs text-tertiary">
|
||||
No visit slots. Add rows for each clinic + day this doctor visits.
|
||||
</p>
|
||||
)}
|
||||
{value.visitSlots.map((slot, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-3"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Clinic"
|
||||
placeholder="Select clinic"
|
||||
items={clinics}
|
||||
selectedKey={slot.clinicId || null}
|
||||
onSelectionChange={(key) =>
|
||||
updateSlot(idx, { clinicId: (key as string) || '' })
|
||||
}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
<Select
|
||||
label="Day"
|
||||
placeholder="Select day"
|
||||
items={DAY_ITEMS}
|
||||
selectedKey={slot.dayOfWeek || null}
|
||||
onSelectionChange={(key) =>
|
||||
updateSlot(idx, { dayOfWeek: (key as DayOfWeek) || '' })
|
||||
}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TimePicker
|
||||
label="Start time"
|
||||
value={slot.startTime}
|
||||
onChange={(startTime) => updateSlot(idx, { startTime })}
|
||||
/>
|
||||
<TimePicker
|
||||
label="End time"
|
||||
value={slot.endTime}
|
||||
onChange={(endTime) => updateSlot(idx, { endTime })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary-destructive"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||
)}
|
||||
onClick={() => removeSlot(idx)}
|
||||
>
|
||||
Remove slot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||
)}
|
||||
onClick={addSlot}
|
||||
className="self-start"
|
||||
>
|
||||
Add visit slot
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
|
||||
205
src/components/forms/employee-create-form.tsx
Normal file
205
src/components/forms/employee-create-form.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash, faRotate } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
|
||||
// In-place employee creation form used by the Team wizard step and
|
||||
// the /settings/team slideout. Replaces the multi-email InviteMemberForm
|
||||
// — this project never uses email invitations, all employees are
|
||||
// created directly with a temp password that the admin hands out.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// - 'create': all fields editable. The temp password is auto-generated
|
||||
// on form mount (parent does this) and revealed via an eye icon. A
|
||||
// refresh icon next to the eye lets the admin re-roll the password
|
||||
// before saving.
|
||||
//
|
||||
// - 'edit': email is read-only (it's the login id, can't change),
|
||||
// password field is hidden entirely (no reset-password from the
|
||||
// wizard). Only firstName/lastName/role can change.
|
||||
//
|
||||
// SIP seat assignment is intentionally NOT in this form — it lives
|
||||
// exclusively in the Telephony wizard step, so there's a single source
|
||||
// of truth for "who is on which seat" and admins don't have to remember
|
||||
// two places to manage the same thing.
|
||||
|
||||
export type RoleOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportingText?: string;
|
||||
};
|
||||
|
||||
export type EmployeeCreateFormValues = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export const emptyEmployeeCreateFormValues: EmployeeCreateFormValues = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roleId: '',
|
||||
};
|
||||
|
||||
// Random temp password generator. Skips visually-ambiguous chars
|
||||
// (0/O/1/l/I) so admins can read the password back over a phone call
|
||||
// without typo risk. 11 alphanumerics + 1 symbol = 12 chars total.
|
||||
export const generateTempPassword = (): string => {
|
||||
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const symbols = '!@#$';
|
||||
let pwd = '';
|
||||
for (let i = 0; i < 11; i++) {
|
||||
pwd += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
pwd += symbols[Math.floor(Math.random() * symbols.length)];
|
||||
return pwd;
|
||||
};
|
||||
|
||||
type EmployeeCreateFormProps = {
|
||||
value: EmployeeCreateFormValues;
|
||||
onChange: (value: EmployeeCreateFormValues) => void;
|
||||
roles: RoleOption[];
|
||||
// 'create' = full form, 'edit' = name + role only.
|
||||
mode?: 'create' | 'edit';
|
||||
};
|
||||
|
||||
// Eye / eye-slash button rendered inside the password field's
|
||||
// trailing slot. Stays internal to this form since password reveal
|
||||
// is the only place we need it right now.
|
||||
const EyeButton = ({ visible, onClick, title }: { visible: boolean; onClick: () => void; title: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const RegenerateButton = ({ onClick }: { onClick: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title="Generate a new password"
|
||||
aria-label="Generate a new password"
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// Kept simple — name + contact + creds + role. No avatar, no phone,
|
||||
// no title. The goal is to get employees onto the system fast; they
|
||||
// can fill in the rest from their own profile page later.
|
||||
export const EmployeeCreateForm = ({
|
||||
value,
|
||||
onChange,
|
||||
roles,
|
||||
mode = 'create',
|
||||
}: EmployeeCreateFormProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const patch = (partial: Partial<EmployeeCreateFormValues>) =>
|
||||
onChange({ ...value, ...partial });
|
||||
|
||||
const isEdit = mode === 'edit';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="First name"
|
||||
placeholder="Priya"
|
||||
value={value.firstName}
|
||||
onChange={(v) => patch({ firstName: v })}
|
||||
isRequired
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
placeholder="Sharma"
|
||||
value={value.lastName}
|
||||
onChange={(v) => patch({ lastName: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="priya@hospital.com"
|
||||
value={value.email}
|
||||
onChange={(v) => patch({ email: v })}
|
||||
isRequired={!isEdit}
|
||||
isReadOnly={isEdit}
|
||||
isDisabled={isEdit}
|
||||
hint={
|
||||
isEdit
|
||||
? 'Email is the login id and cannot be changed.'
|
||||
: 'This is the login id for the employee. Cannot be changed later.'
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Temporary password <span className="text-error-primary">*</span>
|
||||
</label>
|
||||
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 shadow-xs focus-within:border-brand focus-within:ring-2 focus-within:ring-brand-100">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value.password}
|
||||
onChange={(e) => patch({ password: e.target.value })}
|
||||
placeholder="Auto-generated"
|
||||
className="flex-1 bg-transparent py-2 font-mono text-sm text-primary placeholder:text-placeholder outline-none"
|
||||
/>
|
||||
<EyeButton
|
||||
visible={showPassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
/>
|
||||
<RegenerateButton
|
||||
onClick={() => {
|
||||
patch({ password: generateTempPassword() });
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
Auto-generated. Click the refresh icon to roll a new one. Share with the
|
||||
employee directly — they should change it after first login.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Role"
|
||||
placeholder={roles.length === 0 ? 'No roles available' : 'Select a role'}
|
||||
isDisabled={roles.length === 0}
|
||||
items={roles}
|
||||
selectedKey={value.roleId || null}
|
||||
onSelectionChange={(key) => patch({ roleId: (key as string) || '' })}
|
||||
isRequired
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-3 text-xs text-tertiary">
|
||||
SIP seats are managed in the <b>Telephony</b> step — create the employee here
|
||||
first, then assign them a seat there.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserPlus, faTrash, faPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
|
||||
// Multi-email invite form. Uses the platform's sendInvitations mutation which
|
||||
// takes a list of emails; the invited members all get the same role (the
|
||||
// platform doesn't support per-email roles in a single call). If the admin
|
||||
// needs different roles, they invite in multiple batches, or edit roles
|
||||
// afterwards via the team listing.
|
||||
//
|
||||
// Role selection is optional — leaving it blank invites members without a
|
||||
// role, matching the platform's default. When a role is selected, the caller
|
||||
// is expected to apply it via updateWorkspaceMemberRole after the invited
|
||||
// member accepts (this form just reports the selected roleId back).
|
||||
|
||||
export type RoleOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportingText?: string;
|
||||
};
|
||||
|
||||
export type InviteMemberFormValues = {
|
||||
emails: string[];
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type InviteMemberFormProps = {
|
||||
value: InviteMemberFormValues;
|
||||
onChange: (value: InviteMemberFormValues) => void;
|
||||
roles: RoleOption[];
|
||||
};
|
||||
|
||||
export const InviteMemberForm = ({ value, onChange, roles }: InviteMemberFormProps) => {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const addEmail = (rawValue?: string) => {
|
||||
const source = (rawValue ?? draft).trim();
|
||||
if (!source) return;
|
||||
const trimmed = source.replace(/,+$/, '').trim();
|
||||
if (!trimmed) return;
|
||||
if (!EMAIL_REGEX.test(trimmed)) return;
|
||||
if (value.emails.includes(trimmed)) {
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
onChange({ ...value, emails: [...value.emails, trimmed] });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const removeEmail = (email: string) => {
|
||||
onChange({ ...value, emails: value.emails.filter((e) => e !== email) });
|
||||
};
|
||||
|
||||
// The Input component wraps react-aria TextField which doesn't expose
|
||||
// onKeyDown via its typed props — so we commit on comma via the onChange
|
||||
// handler instead. Users can also press the Add button or tab onto it.
|
||||
const handleChange = (next: string) => {
|
||||
if (next.endsWith(',')) {
|
||||
addEmail(next);
|
||||
return;
|
||||
}
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-secondary">
|
||||
Emails <span className="text-error-primary">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="colleague@hospital.com"
|
||||
type="email"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||
)}
|
||||
onClick={() => addEmail()}
|
||||
isDisabled={!draft.trim() || !EMAIL_REGEX.test(draft.trim())}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-tertiary">
|
||||
Type a comma to add multiple emails, or click Add.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{value.emails.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
{value.emails.length} invitee{value.emails.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{value.emails.map((email) => (
|
||||
<li
|
||||
key={email}
|
||||
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-primary">
|
||||
<FontAwesomeIcon icon={faUserPlus} className="size-3.5 text-fg-brand-primary" />
|
||||
{email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEmail(email)}
|
||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Role"
|
||||
placeholder={roles.length === 0 ? 'No roles available' : 'Assign a role'}
|
||||
isDisabled={roles.length === 0}
|
||||
items={roles}
|
||||
selectedKey={value.roleId || null}
|
||||
onSelectionChange={(key) => onChange({ ...value, roleId: (key as string) || '' })}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} supportingText={item.supportingText} />}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { CallWidget } from '@/components/call-desk/call-widget';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
@@ -141,6 +142,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ResumeSetupBanner />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
|
||||
83
src/components/setup/resume-setup-banner.tsx
Normal file
83
src/components/setup/resume-setup-banner.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// the hospital workspace has incomplete setup steps AND the admin has
|
||||
// already dismissed the auto-wizard. This is the "nudge" layer —
|
||||
// a persistent reminder that setup is still outstanding, without the
|
||||
// intrusion of the full-page wizard.
|
||||
//
|
||||
// Visibility rules:
|
||||
// - Admin users only (other roles can't complete setup)
|
||||
// - At least one setup step is still `completed: false`
|
||||
// - `setup-state.wizardDismissed === true` (otherwise the wizard
|
||||
// auto-shows on next login and this banner would be redundant)
|
||||
// - Not dismissed in the current browser session (resets on reload)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// skip the banner. The wizard still works.
|
||||
});
|
||||
}, [isAdmin, dismissed]);
|
||||
|
||||
if (!isAdmin || !state || dismissed) return null;
|
||||
|
||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
// If the wizard hasn't been dismissed yet, the first-run redirect
|
||||
// in login.tsx handles pushing the admin into /setup — no need
|
||||
// for this nudge.
|
||||
if (!state.wizardDismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem('helix_resume_setup_dismissed', '1');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-brand bg-brand-primary px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="size-4 text-brand-primary" />
|
||||
<span className="text-sm text-primary">
|
||||
<b>Finish setting up your hospital</b> — {incompleteCount} step
|
||||
{incompleteCount === 1 ? '' : 's'} still need your attention.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
href="/setup"
|
||||
iconTrailing={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Resume setup
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
title="Dismiss for this session"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/components/setup/wizard-layout-context.tsx
Normal file
27
src/components/setup/wizard-layout-context.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Context that lets each WizardStep render content into the wizard
|
||||
// shell's right pane via a portal — without lifting per-step data
|
||||
// fetching up to the page. The shell sets `rightPaneEl` to the
|
||||
// `<aside>` DOM node once it mounts; child WizardStep components read
|
||||
// it and createPortal their `rightPane` prop into it.
|
||||
//
|
||||
// Why a portal and not a state-lifted prop on WizardShell:
|
||||
// - The right pane is tightly coupled to the active step's data
|
||||
// (e.g. "list of clinics created so far") which lives in the step
|
||||
// component's state. Lifting that state to the page would mean
|
||||
// duplicating the data-fetching layer, OR re-querying everything
|
||||
// from the page.
|
||||
// - Trying to pass `rightPane: ReactNode` upward via callbacks
|
||||
// either causes a one-frame flash (useEffect) or violates the
|
||||
// "no setState during render" rule.
|
||||
// - Portals are React-native, no extra render cycles, and the
|
||||
// DOM target is already part of the layout.
|
||||
|
||||
export type WizardLayoutContextValue = {
|
||||
rightPaneEl: HTMLElement | null;
|
||||
};
|
||||
|
||||
export const WizardLayoutContext = createContext<WizardLayoutContextValue>({
|
||||
rightPaneEl: null,
|
||||
});
|
||||
426
src/components/setup/wizard-right-panes.tsx
Normal file
426
src/components/setup/wizard-right-panes.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBuilding,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCopy,
|
||||
faHeadset,
|
||||
faPenToSquare,
|
||||
faPhone,
|
||||
faRobot,
|
||||
faStethoscope,
|
||||
faUser,
|
||||
faUsers,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
// Reusable right-pane preview components for the onboarding wizard.
|
||||
// Each one is a pure presentation component that takes already-fetched
|
||||
// data as props — the parent step component owns the state + fetches
|
||||
// + refetches after a successful save. Keeping the panes data-only
|
||||
// means the active step can pass the same source of truth to both
|
||||
// the middle (form) pane and this preview without two GraphQL queries
|
||||
// running side by side.
|
||||
|
||||
// Shared title/empty state primitives so every pane has the same
|
||||
// visual rhythm.
|
||||
const PaneCard = ({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">{title}</p>
|
||||
{typeof count === 'number' && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-tertiary">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="px-4 py-6 text-center text-xs text-tertiary">{message}</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity step — short "about this step" card. Explains what the
|
||||
// admin is configuring and where it shows up in the staff portal so
|
||||
// the right pane stays useful even when there's nothing to list yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITY_BULLETS: { title: string; body: string }[] = [
|
||||
{
|
||||
title: 'Hospital name',
|
||||
body: 'Shown on the staff portal sidebar, the login screen, and every patient-facing widget greeting.',
|
||||
},
|
||||
{
|
||||
title: 'Logo',
|
||||
body: 'Used as the avatar at the top of the staff portal and on the website widget header. Square images work best.',
|
||||
},
|
||||
{
|
||||
title: 'Brand identity',
|
||||
body: 'Colors, fonts and login copy live on the full Branding page — open it from Settings any time after setup.',
|
||||
},
|
||||
];
|
||||
|
||||
export const IdentityRightPane = () => (
|
||||
<PaneCard title="About this step">
|
||||
<div className="px-4 py-4">
|
||||
<p className="text-sm text-tertiary">
|
||||
This is how patients and staff first see your hospital across Helix Engage.
|
||||
Get the basics in now — you can polish branding later.
|
||||
</p>
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{IDENTITY_BULLETS.map((b) => (
|
||||
<li key={b.title} className="flex items-start gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleCheck}
|
||||
className="mt-0.5 size-4 shrink-0 text-fg-brand-primary"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">{b.title}</p>
|
||||
<p className="text-xs text-tertiary">{b.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clinics step — list of clinics created so far.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClinicSummary = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
addressCity?: string | null;
|
||||
clinicStatus?: string | null;
|
||||
};
|
||||
|
||||
export const ClinicsRightPane = ({ clinics }: { clinics: ClinicSummary[] }) => (
|
||||
<PaneCard title="Clinics added" count={clinics.length}>
|
||||
{clinics.length === 0 ? (
|
||||
<EmptyState message="No clinics yet — add your first one in the form on the left." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{clinics.map((c) => (
|
||||
<li key={c.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faBuilding} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{c.clinicName ?? 'Unnamed clinic'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{c.addressCity ?? 'No city'}
|
||||
{c.clinicStatus && ` · ${c.clinicStatus.toLowerCase()}`}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctors step — grouped by department, since the user explicitly asked
|
||||
// for "doctors grouped by department" earlier in the design discussion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DoctorSummary = {
|
||||
id: string;
|
||||
fullName: { firstName: string | null; lastName: string | null } | null;
|
||||
department?: string | null;
|
||||
specialty?: string | null;
|
||||
};
|
||||
|
||||
const doctorDisplayName = (d: DoctorSummary): string => {
|
||||
const first = d.fullName?.firstName?.trim() ?? '';
|
||||
const last = d.fullName?.lastName?.trim() ?? '';
|
||||
const full = `${first} ${last}`.trim();
|
||||
return full.length > 0 ? full : 'Unnamed';
|
||||
};
|
||||
|
||||
export const DoctorsRightPane = ({ doctors }: { doctors: DoctorSummary[] }) => {
|
||||
// Group by department. Doctors with no department land in
|
||||
// "Unassigned" so they're not silently dropped.
|
||||
const grouped: Record<string, DoctorSummary[]> = {};
|
||||
for (const d of doctors) {
|
||||
const key = d.department?.trim() || 'Unassigned';
|
||||
(grouped[key] ??= []).push(d);
|
||||
}
|
||||
const sortedKeys = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<PaneCard title="Doctors added" count={doctors.length}>
|
||||
{doctors.length === 0 ? (
|
||||
<EmptyState message="No doctors yet — add your first one in the form on the left." />
|
||||
) : (
|
||||
<div className="divide-y divide-secondary">
|
||||
{sortedKeys.map((dept) => (
|
||||
<div key={dept} className="px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
{dept}{' '}
|
||||
<span className="text-tertiary">({grouped[dept].length})</span>
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{grouped[dept].map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-start gap-2.5 text-sm text-primary"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faStethoscope}
|
||||
className="mt-0.5 size-3.5 text-fg-quaternary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{doctorDisplayName(d)}
|
||||
</p>
|
||||
{d.specialty && (
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{d.specialty}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team step — list of employees with role + SIP badge.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TeamMemberSummary = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
roleLabel: string | null;
|
||||
sipExtension: string | null;
|
||||
// True if this row represents the currently logged-in admin —
|
||||
// suppresses the edit/copy icons since admins shouldn't edit
|
||||
// themselves from the wizard.
|
||||
isCurrentUser: boolean;
|
||||
// True if the parent has the plaintext temp password in memory
|
||||
// (i.e. this employee was created in the current session).
|
||||
// Drives whether the copy icon shows.
|
||||
canCopyCredentials: boolean;
|
||||
};
|
||||
|
||||
const memberDisplayName = (m: TeamMemberSummary): string => {
|
||||
const first = m.name?.firstName?.trim() ?? '';
|
||||
const last = m.name?.lastName?.trim() ?? '';
|
||||
const full = `${first} ${last}`.trim();
|
||||
return full.length > 0 ? full : m.userEmail;
|
||||
};
|
||||
|
||||
// Tiny icon button shared between the edit and copy actions on the
|
||||
// employee row. Kept inline since it's only used here and the styling
|
||||
// matches the existing right-pane density.
|
||||
const RowIconButton = ({
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof faPenToSquare;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
export const TeamRightPane = ({
|
||||
members,
|
||||
onEdit,
|
||||
onCopy,
|
||||
}: {
|
||||
members: TeamMemberSummary[];
|
||||
onEdit?: (memberId: string) => void;
|
||||
onCopy?: (memberId: string) => void;
|
||||
}) => (
|
||||
<PaneCard title="Employees" count={members.length}>
|
||||
{members.length === 0 ? (
|
||||
<EmptyState message="No employees yet — create your first one in the form on the left." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{members.map((m) => (
|
||||
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faUser} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{memberDisplayName(m)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{m.userEmail}
|
||||
{m.roleLabel && ` · ${m.roleLabel}`}
|
||||
</p>
|
||||
{m.sipExtension && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-success-secondary px-2 py-0.5 text-xs font-medium text-success-primary">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-3" />
|
||||
{m.sipExtension}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Admin row gets neither button — admins
|
||||
shouldn't edit themselves from here, and
|
||||
their password isn't in our session
|
||||
memory anyway. */}
|
||||
{!m.isCurrentUser && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{m.canCopyCredentials && onCopy && (
|
||||
<RowIconButton
|
||||
icon={faCopy}
|
||||
title="Copy login credentials"
|
||||
onClick={() => onCopy(m.id)}
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<RowIconButton
|
||||
icon={faPenToSquare}
|
||||
title="Edit employee"
|
||||
onClick={() => onEdit(m.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telephony step — live SIP → member mapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SipSeatSummary = {
|
||||
id: string;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMember: {
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const seatMemberLabel = (m: SipSeatSummary['workspaceMember']): string => {
|
||||
if (!m) return 'Unassigned';
|
||||
const first = m.name?.firstName?.trim() ?? '';
|
||||
const last = m.name?.lastName?.trim() ?? '';
|
||||
const full = `${first} ${last}`.trim();
|
||||
return full.length > 0 ? full : m.userEmail;
|
||||
};
|
||||
|
||||
export const TelephonyRightPane = ({ seats }: { seats: SipSeatSummary[] }) => (
|
||||
<PaneCard title="SIP seats" count={seats.length}>
|
||||
{seats.length === 0 ? (
|
||||
<EmptyState message="No SIP seats configured — contact support to provision seats." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{seats.map((seat) => {
|
||||
const isAssigned = seat.workspaceMember !== null;
|
||||
return (
|
||||
<li key={seat.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div
|
||||
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${
|
||||
isAssigned
|
||||
? 'bg-brand-secondary text-brand-secondary'
|
||||
: 'bg-secondary text-quaternary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
Ext {seat.sipExtension ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{seatMemberLabel(seat.workspaceMember)}
|
||||
</p>
|
||||
</div>
|
||||
{!isAssigned && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-tertiary">
|
||||
Available
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI step — static cards for each configured actor with last-edited info.
|
||||
// Filled in once the backend prompt config refactor lands.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiActorSummary = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
lastEditedAt: string | null;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
export const AiRightPane = ({ actors }: { actors: AiActorSummary[] }) => (
|
||||
<PaneCard title="AI personas" count={actors.length}>
|
||||
{actors.length === 0 ? (
|
||||
<EmptyState message="Loading personas…" />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{actors.map((a) => (
|
||||
<li key={a.key} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faRobot} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{a.label}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{a.isCustom
|
||||
? `Edited ${a.lastEditedAt ? new Date(a.lastEditedAt).toLocaleDateString() : 'recently'}`
|
||||
: 'Default'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// Suppress unused-import warnings for icons reserved for future use.
|
||||
void faCircle;
|
||||
void faUsers;
|
||||
@@ -1,45 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, 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';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardShellProps = {
|
||||
state: SetupState;
|
||||
activeStep: SetupStepName;
|
||||
onSelectStep: (step: SetupStepName) => void;
|
||||
onDismiss: () => void;
|
||||
// Form column (middle pane). The active step component renders
|
||||
// its form into this slot. The right pane is filled via the
|
||||
// WizardLayoutContext + a portal — see wizard-step.tsx.
|
||||
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
|
||||
// Layout shell for the onboarding wizard. Three-pane layout:
|
||||
// left — step navigator (fixed width)
|
||||
// middle — form (flexible, the focus column)
|
||||
// right — preview pane fed by the active step component (sticky,
|
||||
// hides below xl breakpoint)
|
||||
//
|
||||
// The whole shell is `fixed inset-0` so the document body cannot
|
||||
// scroll while the wizard is mounted — fixes the double-scrollbar
|
||||
// bug where the body was rendered taller than the viewport and
|
||||
// scrolled alongside the form column. The form and preview columns
|
||||
// each scroll independently inside the shell.
|
||||
//
|
||||
// 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;
|
||||
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);
|
||||
|
||||
// Callback ref → state — guarantees that consumers re-render once
|
||||
// the aside is mounted (a plain useRef would not propagate the
|
||||
// attached node back through the context). The element is also
|
||||
// updated to null on unmount so the context is always honest about
|
||||
// whether the slot is currently available for portals.
|
||||
const [rightPaneEl, setRightPaneEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary">
|
||||
{/* header */}
|
||||
<header className="border-b border-secondary bg-primary px-8 py-5">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start using your workspace
|
||||
</p>
|
||||
<WizardLayoutContext.Provider value={{ rightPaneEl }}>
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-primary">
|
||||
{/* Header — pinned. Progress bar always visible (grey
|
||||
track when 0%), sits flush under the title row. */}
|
||||
<header className="shrink-0 border-b border-secondary bg-primary">
|
||||
<div className="mx-auto flex w-full max-w-screen-2xl items-center justify-between gap-6 px-8 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="text-xs text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start
|
||||
using your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="link-gray" size="sm" onClick={onDismiss}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
{/* progress bar */}
|
||||
<div className="mx-auto mt-4 max-w-6xl">
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-8 pb-3">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-solid transition-all duration-300"
|
||||
@@ -49,9 +80,13 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* body — step navigator + content */}
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-8 py-8">
|
||||
<nav className="w-72 shrink-0">
|
||||
{/* Body — three columns inside a fixed-height flex row.
|
||||
min-h-0 on the row + each column lets the inner
|
||||
overflow-y-auto actually take effect. */}
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 gap-6 px-8 py-6">
|
||||
{/* Left — step navigator. Scrolls if it overflows on
|
||||
very short viewports, but in practice it fits. */}
|
||||
<nav className="w-60 shrink-0 overflow-y-auto">
|
||||
<ol className="flex flex-col gap-1">
|
||||
{SETUP_STEP_NAMES.map((step, idx) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
@@ -64,7 +99,7 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
type="button"
|
||||
onClick={() => onSelectStep(step)}
|
||||
className={cx(
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-3 text-left transition',
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition',
|
||||
isActive
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-transparent hover:bg-secondary',
|
||||
@@ -75,7 +110,9 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
icon={isComplete ? faCircleCheck : faCircle}
|
||||
className={cx(
|
||||
'size-5',
|
||||
isComplete ? 'text-success-primary' : 'text-quaternary',
|
||||
isComplete
|
||||
? 'text-success-primary'
|
||||
: 'text-quaternary',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -99,8 +136,24 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
{/* Middle — form column. min-w-0 prevents children from
|
||||
forcing the column wider than its flex basis (long
|
||||
inputs, etc.). overflow-y-auto so it scrolls
|
||||
independently of the right pane. */}
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto">{children}</main>
|
||||
|
||||
{/* Right — preview pane. Always rendered as a stable
|
||||
portal target (so the active step's WizardStep can
|
||||
createPortal into it via WizardLayoutContext).
|
||||
Hidden below xl breakpoint (1280px) so the wizard
|
||||
collapses cleanly to two columns on smaller screens.
|
||||
Independent scroll. */}
|
||||
<aside
|
||||
ref={setRightPaneEl}
|
||||
className="hidden w-80 shrink-0 overflow-y-auto xl:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WizardLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
|
||||
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// AI step (post-prompt-config rework). The middle pane has two sections:
|
||||
//
|
||||
// 1. Provider / model / temperature picker — same as before, drives the
|
||||
// provider that all actors use under the hood.
|
||||
// 2. AI personas — list of 7 actor cards, each with name, description,
|
||||
// truncated current template, and an Edit button. Edit triggers a
|
||||
// confirmation modal warning about unintended consequences, then
|
||||
// opens a slideout with the full template + a "variables you can
|
||||
// use" reference + Save / Reset.
|
||||
//
|
||||
// The right pane shows the same 7 personas as compact "last edited" cards
|
||||
// so the admin can scan recent activity at a glance.
|
||||
//
|
||||
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
|
||||
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
|
||||
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
|
||||
// supervisor, lead enrichment, call insight, call assist, recording
|
||||
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
|
||||
// edit here lands instantly.
|
||||
|
||||
type ServerPromptConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
variables: { key: string; description: string }[];
|
||||
template: string;
|
||||
defaultTemplate: string;
|
||||
lastEditedAt: string | null;
|
||||
lastEditedBy: string | null;
|
||||
};
|
||||
|
||||
type ServerAiConfig = {
|
||||
provider?: AiProvider;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
systemPromptAddendum?: string;
|
||||
prompts?: Record<string, ServerPromptConfig>;
|
||||
};
|
||||
|
||||
// AI step — loads the current AI config, lets the admin pick provider and
|
||||
// model, and saves. This is the last step, so on save we fire the finish
|
||||
// flow instead of advancing.
|
||||
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
|
||||
// ai.defaults.ts so the wizard renders personas in the same order
|
||||
// admins see them documented elsewhere.
|
||||
const ACTOR_ORDER = [
|
||||
'widgetChat',
|
||||
'ccAgentHelper',
|
||||
'supervisorChat',
|
||||
'leadEnrichment',
|
||||
'callInsight',
|
||||
'callAssist',
|
||||
'recordingAnalysis',
|
||||
] as const;
|
||||
|
||||
const truncate = (s: string, max: number): string =>
|
||||
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
|
||||
|
||||
export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
|
||||
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<ServerAiConfig>('/api/config/ai', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: data.systemPromptAddendum ?? '',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// non-fatal — defaults will do
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
// Edit flow state — three phases:
|
||||
// 1. confirmingActor: which actor's Edit button was just clicked
|
||||
// (drives the confirmation modal)
|
||||
// 2. editingActor: which actor's slideout is open (only set after
|
||||
// the user confirms past the warning prompt)
|
||||
// 3. draftTemplate: the current textarea contents in the slideout
|
||||
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
|
||||
const [editingActor, setEditingActor] = useState<string | null>(null);
|
||||
const [draftTemplate, setDraftTemplate] = useState('');
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: '',
|
||||
});
|
||||
setPrompts(data.prompts ?? {});
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] fetch failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSaveProviderConfig = async () => {
|
||||
if (!values.model.trim()) {
|
||||
notify.error('Model is required');
|
||||
return;
|
||||
@@ -49,20 +116,92 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
provider: values.provider,
|
||||
model: values.model.trim(),
|
||||
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
|
||||
systemPromptAddendum: values.systemPromptAddendum,
|
||||
});
|
||||
notify.success('AI settings saved', 'Your assistant is ready.');
|
||||
await props.onComplete('ai');
|
||||
// Don't auto-advance — this is the last step, the WizardStep
|
||||
// shell already renders a "Finish setup" button the admin taps
|
||||
// themselves.
|
||||
notify.success('AI settings saved', 'Provider and model updated.');
|
||||
await fetchConfig();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('ai');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save failed', err);
|
||||
console.error('[wizard/ai] save provider failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation modal → slideout flow.
|
||||
const handleEditClick = (actor: string) => {
|
||||
setConfirmingActor(actor);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = () => {
|
||||
if (!confirmingActor) return;
|
||||
const prompt = prompts[confirmingActor];
|
||||
if (!prompt) return;
|
||||
setEditingActor(confirmingActor);
|
||||
setDraftTemplate(prompt.template);
|
||||
setConfirmingActor(null);
|
||||
};
|
||||
|
||||
const handleSavePrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
if (!draftTemplate.trim()) {
|
||||
notify.error('Prompt cannot be empty');
|
||||
return;
|
||||
}
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
|
||||
template: draftTemplate,
|
||||
editedBy: user?.email ?? null,
|
||||
});
|
||||
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
|
||||
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] reset prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build the right-pane summary entries from the loaded prompts.
|
||||
// `isCustom` is true when the template differs from the shipped
|
||||
// default OR when the audit fields are populated — either way the
|
||||
// admin has touched it.
|
||||
const actorSummaries = useMemo<AiActorSummary[]>(() => {
|
||||
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
|
||||
const p = prompts[key];
|
||||
return {
|
||||
key,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
lastEditedAt: p.lastEditedAt,
|
||||
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
|
||||
};
|
||||
});
|
||||
}, [prompts]);
|
||||
|
||||
const editingPrompt = editingActor ? prompts[editingActor] : null;
|
||||
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="ai"
|
||||
@@ -70,15 +209,226 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={handleSaveProviderConfig}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<AiRightPane actors={actorSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading AI settings…</p>
|
||||
) : (
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
<div className="flex flex-col gap-8">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
|
||||
<span className="text-xs text-tertiary">
|
||||
{actorSummaries.length} configurable prompts
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-tertiary">
|
||||
Each persona below is a different AI surface in Helix Engage. Editing a
|
||||
prompt changes how that persona sounds and what rules it follows. Defaults
|
||||
are tuned for hospital call centers — only edit if you have a specific
|
||||
reason and can test the result.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{ACTOR_ORDER.map((key) => {
|
||||
const prompt = prompts[key];
|
||||
if (!prompt) return null;
|
||||
const isCustom =
|
||||
prompt.template !== prompt.defaultTemplate ||
|
||||
prompt.lastEditedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faRobot} className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-semibold text-primary">
|
||||
{prompt.label}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
|
||||
{truncate(prompt.template, 220)}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => handleEditClick(key)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={faPenToSquare}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation modal — reused from the patient-edit gate. */}
|
||||
<EditPatientConfirmModal
|
||||
isOpen={confirmingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmingActor(null);
|
||||
}}
|
||||
onConfirm={handleConfirmEdit}
|
||||
title={`Edit ${confirmingLabel} prompt?`}
|
||||
description={
|
||||
<>
|
||||
Modifying this prompt can affect call quality, lead summaries, and supervisor
|
||||
insights in ways that are hard to predict. The defaults are tuned for hospital
|
||||
call centers — only edit if you have a specific reason and can test the
|
||||
result. You can always reset back to default from the editor.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Yes, edit prompt"
|
||||
/>
|
||||
|
||||
{/* Slideout editor — only opens after the warning is confirmed. */}
|
||||
<SlideoutMenu
|
||||
isOpen={editingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingActor(null);
|
||||
}}
|
||||
isDismissable
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<FontAwesomeIcon
|
||||
icon={faRobot}
|
||||
className="size-5 text-fg-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Edit {editingPrompt?.label}
|
||||
</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{editingPrompt?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Prompt template
|
||||
</label>
|
||||
<textarea
|
||||
value={draftTemplate}
|
||||
onChange={(e) => setDraftTemplate(e.target.value)}
|
||||
rows={18}
|
||||
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
Variables wrapped in <code>{'{{double-braces}}'}</code> get
|
||||
substituted at runtime with live data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Variables you can use
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{editingPrompt.variables.map((v) => (
|
||||
<li key={v.key} className="flex items-start gap-2 text-xs">
|
||||
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
|
||||
{`{{${v.key}}}`}
|
||||
</code>
|
||||
<span className="text-tertiary">
|
||||
{v.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingPrompt?.lastEditedAt && (
|
||||
<p className="text-xs text-tertiary">
|
||||
Last edited{' '}
|
||||
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
|
||||
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
<SlideoutMenu.Footer>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="link-gray"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={() => handleResetPrompt(close)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faRotateLeft} className={className} />
|
||||
)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="secondary"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={savingPrompt}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSavePrompt(close)}
|
||||
>
|
||||
{savingPrompt ? 'Saving…' : 'Save prompt'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
} from '@/components/forms/clinic-form';
|
||||
@@ -10,16 +13,67 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Clinic step — presents a single-clinic form. On save, creates the clinic,
|
||||
// marks the step complete, and advances. The admin can come back to
|
||||
// /settings/clinics later to add more branches or edit existing ones.
|
||||
// Clinic step — presents a single-clinic form. On save the wizard runs
|
||||
// a three-stage create chain:
|
||||
// 1. createClinic (main record → get id)
|
||||
// 2. createHoliday × N (one per holiday entry)
|
||||
// 3. createClinicRequiredDocument × N (one per required doc type)
|
||||
//
|
||||
// We don't pre-load the existing clinic list here because we always want the
|
||||
// form to represent "add a new clinic"; the list page is the right surface
|
||||
// for editing.
|
||||
// This mirrors what the /settings/clinics list page does, minus the
|
||||
// delete-old-first step (wizard is always creating, never updating).
|
||||
// Failures inside the chain throw up through onComplete so the user
|
||||
// sees the error loud, and the wizard stays on the current step.
|
||||
export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
// Field names match what the platform actually exposes:
|
||||
// - the SDK ADDRESS field is named "address" but the
|
||||
// platform mounts it as `addressCustom` (composite type
|
||||
// with addressCity / addressStreet / etc.)
|
||||
// - the SDK SELECT field labelled "Status" lands as plain
|
||||
// `status: ClinicStatusEnum`, NOT `clinicStatus`
|
||||
// Verified via __type introspection — keep this query
|
||||
// pinned to the actual schema to avoid silent empty fetches.
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
addressCustom: { addressCity: string | null } | null;
|
||||
status: string | null;
|
||||
};
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicNode }[] };
|
||||
}>(
|
||||
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node {
|
||||
id clinicName
|
||||
addressCustom { addressCity }
|
||||
status
|
||||
} }
|
||||
} }`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
// Flatten into the shape ClinicsRightPane expects.
|
||||
setClinics(
|
||||
data.clinics.edges.map((e) => ({
|
||||
id: e.node.id,
|
||||
clinicName: e.node.clinicName,
|
||||
addressCity: e.node.addressCustom?.addressCity ?? null,
|
||||
clinicStatus: e.node.status,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] fetch failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClinics();
|
||||
}, [fetchClinics]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!values.clinicName.trim()) {
|
||||
@@ -28,16 +82,54 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core clinic record
|
||||
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: clinicFormToGraphQLInput(values) },
|
||||
{ data: clinicCoreToGraphQLInput(values) },
|
||||
);
|
||||
const clinicId = res.createClinic.id;
|
||||
|
||||
// 2. Holidays
|
||||
if (values.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
holidayInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Required documents
|
||||
if (values.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
docInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Clinic added', values.clinicName);
|
||||
await props.onComplete('clinics');
|
||||
await fetchClinics();
|
||||
// Mark complete on first successful create. Don't auto-advance —
|
||||
// admins typically add multiple clinics in one sitting; the
|
||||
// Continue button on the wizard nav handles forward motion.
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('clinics');
|
||||
}
|
||||
setValues(emptyClinicFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] save failed', err);
|
||||
} finally {
|
||||
@@ -45,24 +137,35 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Same trick as the Team step: once at least one clinic exists,
|
||||
// flip isCompleted=true so the WizardStep renders the "Continue"
|
||||
// button as the primary action — the form stays open below for
|
||||
// adding more clinics.
|
||||
const pretendCompleted = props.isCompleted || clinics.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="clinics"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<ClinicsRightPane clinics={clinics} />}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one clinic. Fill the form again to add another, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<ClinicForm value={values} onChange={setValues} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Adding…' : 'Add clinic'}
|
||||
</button>
|
||||
</div>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { DoctorsRightPane, type DoctorSummary } from './wizard-right-panes';
|
||||
import {
|
||||
DoctorForm,
|
||||
doctorFormToGraphQLInput,
|
||||
doctorCoreToGraphQLInput,
|
||||
visitSlotInputsFromForm,
|
||||
emptyDoctorFormValues,
|
||||
type DoctorFormValues,
|
||||
} from '@/components/forms/doctor-form';
|
||||
@@ -20,21 +22,40 @@ type ClinicLite = { id: string; clinicName: string | null };
|
||||
export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
|
||||
const [clinics, setClinics] = useState<ClinicLite[]>([]);
|
||||
const [doctors, setDoctors] = useState<DoctorSummary[]>([]);
|
||||
const [loadingClinics, setLoadingClinics] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{ clinics: { edges: { node: ClinicLite }[] } }>(
|
||||
`{ clinics(first: 100) { edges { node { id clinicName } } } }`,
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicLite }[] };
|
||||
doctors: { edges: { node: DoctorSummary }[] };
|
||||
}>(
|
||||
`{
|
||||
clinics(first: 100) { edges { node { id clinicName } } }
|
||||
doctors(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node { id fullName { firstName lastName } department specialty } }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
)
|
||||
.then((data) => setClinics(data.clinics.edges.map((e) => e.node)))
|
||||
.catch(() => setClinics([]))
|
||||
.finally(() => setLoadingClinics(false));
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
setDoctors(data.doctors.edges.map((e) => e.node));
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] fetch failed', err);
|
||||
setClinics([]);
|
||||
setDoctors([]);
|
||||
} finally {
|
||||
setLoadingClinics(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const clinicOptions = useMemo(
|
||||
() => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })),
|
||||
[clinics],
|
||||
@@ -47,16 +68,37 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core doctor record
|
||||
const res = await apiClient.graphql<{ createDoctor: { id: string } }>(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data: doctorFormToGraphQLInput(values) },
|
||||
{ data: doctorCoreToGraphQLInput(values) },
|
||||
);
|
||||
const doctorId = res.createDoctor.id;
|
||||
|
||||
// 2. Visit slots (doctor can be at multiple clinics on
|
||||
// multiple days with different times each).
|
||||
const slotInputs = visitSlotInputsFromForm(values, doctorId);
|
||||
if (slotInputs.length > 0) {
|
||||
await Promise.all(
|
||||
slotInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
|
||||
createDoctorVisitSlot(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`);
|
||||
await props.onComplete('doctors');
|
||||
await fetchData();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('doctors');
|
||||
}
|
||||
setValues(emptyDoctorFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] save failed', err);
|
||||
} finally {
|
||||
@@ -64,16 +106,19 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pretendCompleted = props.isCompleted || doctors.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="doctors"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<DoctorsRightPane doctors={doctors} />}
|
||||
>
|
||||
{loadingClinics ? (
|
||||
<p className="text-sm text-tertiary">Loading clinics…</p>
|
||||
@@ -87,13 +132,17 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one doctor. Fill the form again to add another, or
|
||||
click <b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Adding…' : 'Add doctor'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WizardStep>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { IdentityRightPane } from './wizard-right-panes';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
@@ -74,6 +75,7 @@ export const WizardStepIdentity = (props: WizardStepComponentProps) => {
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<IdentityRightPane />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading current branding…</p>
|
||||
|
||||
@@ -1,100 +1,441 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
|
||||
import {
|
||||
InviteMemberForm,
|
||||
type InviteMemberFormValues,
|
||||
EmployeeCreateForm,
|
||||
emptyEmployeeCreateFormValues,
|
||||
generateTempPassword,
|
||||
type EmployeeCreateFormValues,
|
||||
type RoleOption,
|
||||
} from '@/components/forms/invite-member-form';
|
||||
} from '@/components/forms/employee-create-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Team step — fetch roles from the platform and present the invite form.
|
||||
// The admin types one or more emails and picks a role. sendInvitations
|
||||
// fires, the backend emails them, and the wizard advances on success.
|
||||
// Team step (post-rework) — creates workspace members directly from
|
||||
// the portal via the sidecar's /api/team/members endpoint. The admin
|
||||
// enters name + email + temp password + role. SIP seat assignment is
|
||||
// NOT done here — it lives exclusively in the Telephony wizard step
|
||||
// so admins manage one thing in one place.
|
||||
//
|
||||
// Role assignment itself happens AFTER the invitee accepts (since we only
|
||||
// have a workspaceMemberId once they've joined the workspace). For now we
|
||||
// just send the invitations — the admin can finalise role assignments
|
||||
// from /settings/team once everyone has accepted.
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<InviteMemberFormValues>({ emails: [], roleId: '' });
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Edit mode: clicking the pencil icon on an employee row in the right
|
||||
// pane loads that member back into the form (name + role only — email,
|
||||
// password and SIP seat are not editable here). Save in edit mode
|
||||
// fires PUT /api/team/members/:id instead of POST.
|
||||
//
|
||||
// Email invitations are NOT used anywhere in this flow. The admin is
|
||||
// expected to share the temp password with the employee directly.
|
||||
// Recently-created employees keep their plaintext password in
|
||||
// component state so the right pane's copy icon can paste a
|
||||
// shareable credentials block to the clipboard. Page reload clears
|
||||
// that state — only employees created in the current session show
|
||||
// the copy icon. Older members get only the edit icon.
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{
|
||||
getRoles: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
}[];
|
||||
}>(`{ getRoles { id label description canBeAssignedToUsers } }`, undefined, { silent: true })
|
||||
.then((data) =>
|
||||
setRoles(
|
||||
data.getRoles
|
||||
.filter((r) => r.canBeAssignedToUsers)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => setRoles([]));
|
||||
// In-memory record of an employee the admin just created in this
|
||||
// session. Holds the plaintext temp password so the copy-icon flow
|
||||
// works without ever sending the password back from the server.
|
||||
type CreatedMemberRecord = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
tempPassword: string;
|
||||
};
|
||||
|
||||
type RoleRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
};
|
||||
|
||||
type AgentRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMemberId: string | null;
|
||||
workspaceMember: {
|
||||
id: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type WorkspaceMemberRow = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
// Platform returns `null` (not an empty array) for members with no
|
||||
// role assigned — touching `.roles[0]` directly throws. Always
|
||||
// optional-chain reads.
|
||||
roles: { id: string; label: string }[] | null;
|
||||
};
|
||||
|
||||
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
|
||||
|
||||
// Build the credentials block that gets copied to the clipboard. Two
|
||||
// lines (login url + email) plus the temp password — formatted so
|
||||
// the admin can paste it straight into WhatsApp / SMS. Login URL is
|
||||
// derived from the current browser origin since the wizard is always
|
||||
// loaded from the workspace's own URL (or Vite dev), so this matches
|
||||
// what the employee will use.
|
||||
const buildCredentialsBlock = (email: string, tempPassword: string): string => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`;
|
||||
};
|
||||
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const currentUserEmail = user?.email ?? null;
|
||||
|
||||
// Initialise the form with a fresh temp password so the admin
|
||||
// doesn't have to click "regenerate" before saving the very first
|
||||
// employee.
|
||||
const [values, setValues] = useState<EmployeeCreateFormValues>(() => ({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
}));
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
// Agents are still fetched (even though we don't show a SIP seat
|
||||
// picker here) because the right-pane summary needs each member's
|
||||
// current SIP extension to show the green badge.
|
||||
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||||
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
|
||||
const [createdMembers, setCreatedMembers] = useState<CreatedMemberRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isEditing = editingMemberId !== null;
|
||||
|
||||
const fetchRolesAndAgents = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
getRoles: RoleRow[];
|
||||
agents: { edges: { node: AgentRow }[] };
|
||||
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
|
||||
}>(
|
||||
`{
|
||||
getRoles { id label description canBeAssignedToUsers }
|
||||
agents(first: 100) {
|
||||
edges { node {
|
||||
id name sipExtension ozonetelAgentId workspaceMemberId
|
||||
workspaceMember { id name { firstName lastName } userEmail }
|
||||
} }
|
||||
}
|
||||
workspaceMembers(first: 200) {
|
||||
edges { node {
|
||||
id userEmail name { firstName lastName }
|
||||
roles { id label }
|
||||
} }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
|
||||
setRoles(
|
||||
assignable.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
);
|
||||
setAgents(data.agents.edges.map((e) => e.node));
|
||||
setMembers(
|
||||
data.workspaceMembers.edges
|
||||
.map((e) => e.node)
|
||||
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] fetch roles/agents failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (values.emails.length === 0) {
|
||||
notify.error('Add at least one email');
|
||||
useEffect(() => {
|
||||
fetchRolesAndAgents();
|
||||
}, [fetchRolesAndAgents]);
|
||||
|
||||
// Reset form back to a fresh "create" state with a new auto-gen
|
||||
// password. Used after both create-success and edit-cancel.
|
||||
const resetForm = () => {
|
||||
setEditingMemberId(null);
|
||||
setValues({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveCreate = async () => {
|
||||
const firstName = values.firstName.trim();
|
||||
const email = values.email.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
notify.error('Email is required');
|
||||
return;
|
||||
}
|
||||
if (!values.password) {
|
||||
notify.error('Temporary password is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) { success errors }
|
||||
}`,
|
||||
{ emails: values.emails },
|
||||
);
|
||||
const created = await apiClient.post<{
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
}>('/api/team/members', {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
email,
|
||||
password: values.password,
|
||||
roleId: values.roleId,
|
||||
});
|
||||
|
||||
// Stash the plaintext temp password alongside the created
|
||||
// member so the copy-icon can build a credentials block
|
||||
// later. The password is NOT sent back from the server —
|
||||
// we hold the only copy in this component's memory.
|
||||
setCreatedMembers((prev) => [
|
||||
...prev,
|
||||
{ ...created, tempPassword: values.password },
|
||||
]);
|
||||
notify.success(
|
||||
'Invitations sent',
|
||||
`${values.emails.length} invitation${values.emails.length === 1 ? '' : 's'} sent.`,
|
||||
'Employee created',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || email,
|
||||
);
|
||||
await props.onComplete('team');
|
||||
setValues({ emails: [], roleId: '' });
|
||||
props.onAdvance();
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('team');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] invite failed', err);
|
||||
console.error('[wizard/team] create failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!editingMemberId) return;
|
||||
const firstName = values.firstName.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(`/api/team/members/${editingMemberId}`, {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
roleId: values.roleId,
|
||||
});
|
||||
notify.success(
|
||||
'Employee updated',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || values.email,
|
||||
);
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] update failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate;
|
||||
|
||||
// Right-pane edit handler — populate the form with the picked
|
||||
// member's data and switch into edit mode. Email is preserved as
|
||||
// the row's email (read-only in edit mode); password is cleared
|
||||
// since the form hides the field anyway.
|
||||
const handleEditMember = (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
const firstRole = member.roles?.[0] ?? null;
|
||||
setEditingMemberId(memberId);
|
||||
setValues({
|
||||
firstName: member.name?.firstName ?? '',
|
||||
lastName: member.name?.lastName ?? '',
|
||||
email: member.userEmail,
|
||||
password: '',
|
||||
roleId: firstRole?.id ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
// Right-pane copy handler — build the shareable credentials block
|
||||
// and put it on the clipboard. Only fires for members in the
|
||||
// createdMembers in-memory map; rows without a known temp password
|
||||
// don't show the icon at all.
|
||||
const handleCopyCredentials = async (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
// Three-tier fallback:
|
||||
// 1. In-browser memory (createdMembers state) — populated when
|
||||
// the admin created this employee in the current session,
|
||||
// survives until refresh. Fastest path, no network call.
|
||||
// 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password
|
||||
// — populated for any member created via this endpoint
|
||||
// within the last 24h, survives reloads.
|
||||
// 3. Cache miss → tell the admin the password is no longer
|
||||
// recoverable and direct them to the platform reset flow.
|
||||
const fromMemory =
|
||||
createdMembers.find(
|
||||
(c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(),
|
||||
) ?? createdMembers.find((c) => c.id === memberId);
|
||||
let tempPassword = fromMemory?.tempPassword ?? null;
|
||||
|
||||
if (!tempPassword) {
|
||||
try {
|
||||
const res = await apiClient.get<{ password: string | null }>(
|
||||
`/api/team/members/${memberId}/temp-password`,
|
||||
{ silent: true },
|
||||
);
|
||||
tempPassword = res.password;
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] temp-password fetch failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tempPassword) {
|
||||
notify.error(
|
||||
'Password unavailable',
|
||||
'The temp password expired (>24h). Reset the password from settings to mint a new one.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = buildCredentialsBlock(member.userEmail, tempPassword);
|
||||
try {
|
||||
await navigator.clipboard.writeText(block);
|
||||
notify.success('Copied', 'Credentials copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] clipboard write failed', err);
|
||||
notify.error('Copy failed', 'Could not write to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Trick: we lie to WizardStep about isCompleted so that once at
|
||||
// least one employee exists, the primary wizard button flips to
|
||||
// "Continue" and the create form stays available below for more
|
||||
// adds.
|
||||
const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0;
|
||||
|
||||
// Build the right pane summary. Every non-admin row gets the
|
||||
// copy icon — `canCopyCredentials: true` unconditionally — and
|
||||
// the click handler figures out at action time whether to read
|
||||
// from in-browser memory or the sidecar's Redis cache. If both
|
||||
// are empty (>24h old), the click toasts a "password expired"
|
||||
// message instead of silently failing.
|
||||
const teamSummaries = useMemo<TeamMemberSummary[]>(
|
||||
() =>
|
||||
members.map((m) => {
|
||||
const seat = agents.find((a) => a.workspaceMemberId === m.id);
|
||||
const firstRole = m.roles?.[0] ?? null;
|
||||
return {
|
||||
id: m.id,
|
||||
userEmail: m.userEmail,
|
||||
name: m.name,
|
||||
roleLabel: firstRole?.label ?? null,
|
||||
sipExtension: seat?.sipExtension ?? null,
|
||||
isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail,
|
||||
canCopyCredentials: true,
|
||||
};
|
||||
}),
|
||||
[members, agents, currentUserEmail],
|
||||
);
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="team"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={
|
||||
<TeamRightPane
|
||||
members={teamSummaries}
|
||||
onEdit={handleEditMember}
|
||||
onCopy={handleCopyCredentials}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
Invitations already sent. Add more emails below to invite additional members, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading team settings…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
{isEditing ? (
|
||||
<p>
|
||||
Editing an existing employee. You can change their name and role.
|
||||
To change their SIP seat, go to the <b>Telephony</b> step.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Create employees in-place. Each person gets an auto-generated
|
||||
temporary password that you share directly — no email
|
||||
invitations are sent. Click the eye icon to reveal it before
|
||||
you save. After creating CC agents, head to the <b>Telephony</b>{' '}
|
||||
step to assign them SIP seats.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EmployeeCreateForm
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
roles={roles}
|
||||
mode={isEditing ? 'edit' : 'create'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isEditing && (
|
||||
<Button size="md" color="secondary" isDisabled={saving} onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving
|
||||
? isEditing
|
||||
? 'Updating…'
|
||||
: 'Creating…'
|
||||
: isEditing
|
||||
? 'Update employee'
|
||||
: 'Create employee'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<InviteMemberForm value={values} onChange={setValues} roles={roles} />
|
||||
<p className="mt-4 text-xs text-tertiary">
|
||||
Invited members receive an email with a link to set their password. Fine-tune role assignments
|
||||
from the Team page after they join.
|
||||
</p>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,101 +1,321 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import {
|
||||
TelephonyForm,
|
||||
emptyTelephonyFormValues,
|
||||
type TelephonyFormValues,
|
||||
} from '@/components/forms/telephony-form';
|
||||
import { TelephonyRightPane, type SipSeatSummary } from './wizard-right-panes';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Telephony step — loads the existing masked config from the sidecar and
|
||||
// lets the admin fill in the Ozonetel/SIP/Exotel credentials. On save, PUTs
|
||||
// the full form (the backend treats '***masked***' as "no change") and
|
||||
// marks the step complete.
|
||||
// Telephony step (post-3-pane rework). The middle pane is now an
|
||||
// assign/unassign editor: pick a SIP seat, pick a workspace member,
|
||||
// click Assign — or pick an already-mapped seat and click Unassign.
|
||||
// The right pane shows the live current state (read-only mapping
|
||||
// summary). Editing here calls updateAgent to set/clear
|
||||
// workspaceMemberId, then refetches.
|
||||
//
|
||||
// Unlike the entity steps, this is a single-doc config so we always load the
|
||||
// current state rather than treating the form as "add new".
|
||||
// SIP seats themselves are pre-provisioned by onboard-hospital.sh
|
||||
// (see step 5b) — admins can't add or delete seats from this UI,
|
||||
// only link them to people. To add a new seat, contact support.
|
||||
|
||||
type AgentRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMemberId: string | null;
|
||||
workspaceMember: {
|
||||
id: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type WorkspaceMemberRow = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
};
|
||||
|
||||
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
|
||||
|
||||
const memberDisplayName = (m: {
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
}): string => {
|
||||
const first = m.name?.firstName?.trim() ?? '';
|
||||
const last = m.name?.lastName?.trim() ?? '';
|
||||
const full = `${first} ${last}`.trim();
|
||||
return full.length > 0 ? full : m.userEmail;
|
||||
};
|
||||
|
||||
export const WizardStepTelephony = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<TelephonyFormValues>(emptyTelephonyFormValues);
|
||||
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||||
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Editor state — which seat is selected, which member to assign.
|
||||
const [selectedSeatId, setSelectedSeatId] = useState<string>('');
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<TelephonyFormValues>('/api/config/telephony', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
ozonetel: {
|
||||
agentId: data.ozonetel?.agentId ?? '',
|
||||
agentPassword: data.ozonetel?.agentPassword ?? '',
|
||||
did: data.ozonetel?.did ?? '',
|
||||
sipId: data.ozonetel?.sipId ?? '',
|
||||
campaignName: data.ozonetel?.campaignName ?? '',
|
||||
},
|
||||
sip: {
|
||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
wsPort: data.sip?.wsPort ?? '444',
|
||||
},
|
||||
exotel: {
|
||||
apiKey: data.exotel?.apiKey ?? '',
|
||||
apiToken: data.exotel?.apiToken ?? '',
|
||||
accountSid: data.exotel?.accountSid ?? '',
|
||||
subdomain: data.exotel?.subdomain ?? 'api.exotel.com',
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// If the endpoint is unreachable, fall back to defaults so the
|
||||
// admin can at least fill out the form.
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
agents: { edges: { node: AgentRow }[] };
|
||||
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
|
||||
}>(
|
||||
`{
|
||||
agents(first: 100) {
|
||||
edges { node {
|
||||
id name sipExtension ozonetelAgentId workspaceMemberId
|
||||
workspaceMember { id name { firstName lastName } userEmail }
|
||||
} }
|
||||
}
|
||||
workspaceMembers(first: 200) {
|
||||
edges { node {
|
||||
id userEmail name { firstName lastName }
|
||||
} }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
setAgents(data.agents.edges.map((e) => e.node));
|
||||
setMembers(
|
||||
data.workspaceMembers.edges
|
||||
.map((e) => e.node)
|
||||
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/telephony] fetch failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Required fields for a working Ozonetel setup.
|
||||
if (
|
||||
!values.ozonetel.agentId.trim() ||
|
||||
!values.ozonetel.did.trim() ||
|
||||
!values.ozonetel.sipId.trim() ||
|
||||
!values.ozonetel.campaignName.trim()
|
||||
) {
|
||||
notify.error('Missing required fields', 'Agent ID, DID, SIP ID, and campaign name are all required.');
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Map every agent to a SipSeatSummary for the right pane. Single
|
||||
// source of truth — both panes read from `agents`.
|
||||
const seatSummaries = useMemo<SipSeatSummary[]>(
|
||||
() =>
|
||||
agents.map((a) => ({
|
||||
id: a.id,
|
||||
sipExtension: a.sipExtension,
|
||||
ozonetelAgentId: a.ozonetelAgentId,
|
||||
workspaceMember: a.workspaceMember,
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Pre-compute lookups for the editor — which member already owns
|
||||
// each seat, and which members are already taken (so the dropdown
|
||||
// can hide them).
|
||||
const takenMemberIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
agents
|
||||
.filter((a) => a.workspaceMemberId !== null)
|
||||
.map((a) => a.workspaceMemberId!),
|
||||
),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const seatItems = useMemo(
|
||||
() =>
|
||||
agents.map((a) => ({
|
||||
id: a.id,
|
||||
label: `Ext ${a.sipExtension ?? '—'}`,
|
||||
supportingText: a.workspaceMember
|
||||
? `Currently: ${memberDisplayName(a.workspaceMember)}`
|
||||
: 'Unassigned',
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Members dropdown — when a seat is selected and the seat is
|
||||
// currently mapped, force the member field to show the current
|
||||
// owner so the admin can see who they're displacing. When seat
|
||||
// is unassigned, only show free members (the takenMemberIds
|
||||
// filter).
|
||||
const memberItems = useMemo(() => {
|
||||
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
|
||||
const currentOwnerId = selectedSeat?.workspaceMemberId ?? null;
|
||||
return members
|
||||
.filter((m) => m.id === currentOwnerId || !takenMemberIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
label: memberDisplayName(m),
|
||||
supportingText: m.userEmail,
|
||||
}));
|
||||
}, [members, agents, selectedSeatId, takenMemberIds]);
|
||||
|
||||
// When the admin picks a seat, default the member dropdown to
|
||||
// whoever currently owns it (if anyone) so Unassign just works.
|
||||
useEffect(() => {
|
||||
if (!selectedSeatId) {
|
||||
setSelectedMemberId('');
|
||||
return;
|
||||
}
|
||||
const seat = agents.find((a) => a.id === selectedSeatId);
|
||||
setSelectedMemberId(seat?.workspaceMemberId ?? '');
|
||||
}, [selectedSeatId, agents]);
|
||||
|
||||
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
|
||||
const isCurrentlyMapped = selectedSeat?.workspaceMemberId !== null && selectedSeat?.workspaceMemberId !== undefined;
|
||||
|
||||
const updateSeat = async (seatId: string, workspaceMemberId: string | null) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put('/api/config/telephony', {
|
||||
ozonetel: values.ozonetel,
|
||||
sip: values.sip,
|
||||
exotel: values.exotel,
|
||||
});
|
||||
notify.success('Telephony saved', 'Changes are live — no restart needed.');
|
||||
await props.onComplete('telephony');
|
||||
props.onAdvance();
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAgent($id: UUID!, $data: AgentUpdateInput!) {
|
||||
updateAgent(id: $id, data: $data) { id workspaceMemberId }
|
||||
}`,
|
||||
{ id: seatId, data: { workspaceMemberId } },
|
||||
);
|
||||
await fetchData();
|
||||
// Mark the step complete on first successful action so
|
||||
// the wizard can advance. Subsequent edits don't re-mark.
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('telephony');
|
||||
}
|
||||
// Clear editor selection so the admin starts the next
|
||||
// assign from scratch.
|
||||
setSelectedSeatId('');
|
||||
setSelectedMemberId('');
|
||||
} catch (err) {
|
||||
console.error('[wizard/telephony] save failed', err);
|
||||
console.error('[wizard/telephony] updateAgent failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!selectedSeatId || !selectedMemberId) {
|
||||
notify.error('Pick a seat and a member to assign');
|
||||
return;
|
||||
}
|
||||
updateSeat(selectedSeatId, selectedMemberId);
|
||||
};
|
||||
|
||||
const handleUnassign = () => {
|
||||
if (!selectedSeatId) return;
|
||||
updateSeat(selectedSeatId, null);
|
||||
};
|
||||
|
||||
const pretendCompleted = props.isCompleted || agents.some((a) => a.workspaceMemberId !== null);
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="telephony"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={async () => {
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('telephony');
|
||||
}
|
||||
props.onAdvance();
|
||||
}}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<TelephonyRightPane seats={seatSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading telephony settings…</p>
|
||||
<p className="text-sm text-tertiary">Loading SIP seats…</p>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-6 text-sm text-tertiary">
|
||||
<p className="font-medium text-primary">No SIP seats configured</p>
|
||||
<p className="mt-1">
|
||||
This hospital has no pre-provisioned agent profiles. Contact support to
|
||||
add SIP seats, then come back to finish setup.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TelephonyForm value={values} onChange={setValues} />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
<p>
|
||||
Pick a SIP seat and assign it to a workspace member. To free up a seat,
|
||||
select it and click <b>Unassign</b>. The right pane shows the live
|
||||
mapping — what you change here updates there immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="SIP seat"
|
||||
placeholder="Select a seat"
|
||||
items={seatItems}
|
||||
selectedKey={selectedSeatId || null}
|
||||
onSelectionChange={(key) => setSelectedSeatId((key as string) || '')}
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Workspace member"
|
||||
placeholder={
|
||||
!selectedSeatId
|
||||
? 'Pick a seat first'
|
||||
: memberItems.length === 0
|
||||
? 'No available members'
|
||||
: 'Select a member'
|
||||
}
|
||||
isDisabled={!selectedSeatId || memberItems.length === 0}
|
||||
items={memberItems}
|
||||
selectedKey={selectedMemberId || null}
|
||||
onSelectionChange={(key) => setSelectedMemberId((key as string) || '')}
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isCurrentlyMapped && (
|
||||
<Button
|
||||
color="secondary-destructive"
|
||||
size="md"
|
||||
isDisabled={saving}
|
||||
onClick={handleUnassign}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||
)}
|
||||
>
|
||||
Unassign
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={saving || !selectedSeatId || !selectedMemberId}
|
||||
onClick={handleAssign}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faHeadset} className={className} />
|
||||
)}
|
||||
>
|
||||
{selectedSeat?.workspaceMemberId === selectedMemberId
|
||||
? 'Already assigned'
|
||||
: isCurrentlyMapped
|
||||
? 'Reassign'
|
||||
: 'Assign'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WizardStep>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useContext, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardStepProps = {
|
||||
step: SetupStepName;
|
||||
@@ -14,6 +16,11 @@ type WizardStepProps = {
|
||||
onFinish: () => void;
|
||||
saving?: boolean;
|
||||
children: ReactNode;
|
||||
// Optional content for the wizard shell's right preview pane.
|
||||
// Portaled into the shell's <aside> via WizardLayoutContext when
|
||||
// both are mounted. Each step component declares this inline so
|
||||
// the per-step data fetching stays in one place.
|
||||
rightPane?: ReactNode;
|
||||
};
|
||||
|
||||
// Single-step wrapper. The parent picks which step is active and supplies
|
||||
@@ -31,10 +38,14 @@ export const WizardStep = ({
|
||||
onFinish,
|
||||
saving = false,
|
||||
children,
|
||||
rightPane,
|
||||
}: WizardStepProps) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
const { rightPaneEl } = useContext(WizardLayoutContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rightPane && rightPaneEl && createPortal(rightPane, rightPaneEl)}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-8 shadow-xs">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -64,30 +75,55 @@ export const WizardStep = ({
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* One primary action at the bottom — never two
|
||||
competing buttons. Previously the wizard showed
|
||||
Mark complete + Next side-by-side, and users
|
||||
naturally clicked Next (rightmost = "continue"),
|
||||
skipping the save+complete chain entirely. Result
|
||||
was every step staying at 0/6.
|
||||
|
||||
New behaviour: a single button whose label and
|
||||
handler depend on completion state.
|
||||
- !isCompleted, not last → "Save and continue"
|
||||
calls onMarkComplete (which does save +
|
||||
complete + advance via the step component's
|
||||
handleSave). Forces the agent through the
|
||||
completion path.
|
||||
- !isCompleted, last → "Save and finish"
|
||||
same chain, plus onFinish at the end.
|
||||
- isCompleted, not last → "Continue"
|
||||
calls onNext (pure navigation).
|
||||
- isCompleted, last → "Finish setup"
|
||||
calls onFinish.
|
||||
|
||||
Free-form navigation is still available via the
|
||||
left-side step nav, so users can revisit completed
|
||||
steps without re-saving. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!isCompleted && (
|
||||
{!isCompleted ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isLoading={saving}
|
||||
showTextWhileLoading
|
||||
onClick={onMarkComplete}
|
||||
iconTrailing={
|
||||
isLast
|
||||
? undefined
|
||||
: ({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark complete
|
||||
{isLast ? 'Save and finish' : 'Save and continue'}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!isCompleted}
|
||||
onClick={onFinish}
|
||||
>
|
||||
) : isLast ? (
|
||||
<Button color="primary" size="md" onClick={onFinish}>
|
||||
Finish setup
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color={isCompleted ? 'primary' : 'secondary'}
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!onNext}
|
||||
onClick={onNext ?? undefined}
|
||||
@@ -95,11 +131,12 @@ export const WizardStep = ({
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Next
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,21 +8,37 @@ import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-m
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
type ClinicStatus,
|
||||
type DocumentType,
|
||||
type ClinicHolidayEntry,
|
||||
} from '@/components/forms/clinic-form';
|
||||
import { formatTimeLabel } from '@/components/application/date-picker/time-picker';
|
||||
import { formatDaySelection, type DaySelection } from '@/components/application/day-selector/day-selector';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/clinics — list + add/edit slideout for the clinic entity. Uses the
|
||||
// platform GraphQL API directly; there's no wrapping hook because this is the
|
||||
// only consumer of clinics CRUD (the call desk uses a read-only doctors
|
||||
// query and bypasses clinics entirely).
|
||||
// /settings/clinics — list + add/edit slideout. Schema aligns with the
|
||||
// reworked Clinic entity in helix-engage/src/objects/clinic.object.ts:
|
||||
// - openMonday..openSunday (7 BOOLEANs) for the weekly pattern
|
||||
// - opensAt/closesAt (TEXT, HH:MM) for the shared daily time range
|
||||
// - two child entities: Holiday (closures) and ClinicRequiredDocument
|
||||
// (required-doc selection per clinic)
|
||||
//
|
||||
// Save flow:
|
||||
// 1. createClinic / updateClinic (main record)
|
||||
// 2. Fire child mutations in parallel:
|
||||
// - For holidays: delete-all-recreate on edit (simple, idempotent)
|
||||
// - For required docs: diff old vs new, delete removed, create added
|
||||
|
||||
type Clinic = {
|
||||
// -- Fetched shapes from the platform ----------------------------------------
|
||||
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
status: ClinicStatus | null;
|
||||
@@ -35,14 +51,28 @@ type Clinic = {
|
||||
} | null;
|
||||
phone: { primaryPhoneNumber: string | null } | null;
|
||||
email: { primaryEmail: string | null } | null;
|
||||
weekdayHours: string | null;
|
||||
saturdayHours: string | null;
|
||||
sundayHours: string | null;
|
||||
openMonday: boolean | null;
|
||||
openTuesday: boolean | null;
|
||||
openWednesday: boolean | null;
|
||||
openThursday: boolean | null;
|
||||
openFriday: boolean | null;
|
||||
openSaturday: boolean | null;
|
||||
openSunday: boolean | null;
|
||||
opensAt: string | null;
|
||||
closesAt: string | null;
|
||||
walkInAllowed: boolean | null;
|
||||
onlineBooking: boolean | null;
|
||||
cancellationWindowHours: number | null;
|
||||
arriveEarlyMin: number | null;
|
||||
requiredDocuments: string | null;
|
||||
// Reverse-side collections. Platform exposes them as Relay edges.
|
||||
holidays?: {
|
||||
edges: Array<{
|
||||
node: { id: string; date: string | null; reasonLabel: string | null };
|
||||
}>;
|
||||
};
|
||||
clinicRequiredDocuments?: {
|
||||
edges: Array<{ node: { id: string; documentType: DocumentType | null } }>;
|
||||
};
|
||||
};
|
||||
|
||||
const CLINICS_QUERY = `{
|
||||
@@ -57,16 +87,34 @@ const CLINICS_QUERY = `{
|
||||
}
|
||||
phone { primaryPhoneNumber }
|
||||
email { primaryEmail }
|
||||
weekdayHours saturdayHours sundayHours
|
||||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||
opensAt closesAt
|
||||
walkInAllowed onlineBooking
|
||||
cancellationWindowHours arriveEarlyMin
|
||||
requiredDocuments
|
||||
holidays(first: 50) {
|
||||
edges { node { id date reasonLabel } }
|
||||
}
|
||||
clinicRequiredDocuments(first: 50) {
|
||||
edges { node { id documentType } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
const toDaySelection = (c: ClinicNode): DaySelection => ({
|
||||
monday: !!c.openMonday,
|
||||
tuesday: !!c.openTuesday,
|
||||
wednesday: !!c.openWednesday,
|
||||
thursday: !!c.openThursday,
|
||||
friday: !!c.openFriday,
|
||||
saturday: !!c.openSaturday,
|
||||
sunday: !!c.openSunday,
|
||||
});
|
||||
|
||||
const toFormValues = (clinic: ClinicNode): ClinicFormValues => ({
|
||||
clinicName: clinic.clinicName ?? '',
|
||||
addressStreet1: clinic.addressCustom?.addressStreet1 ?? '',
|
||||
addressStreet2: clinic.addressCustom?.addressStreet2 ?? '',
|
||||
@@ -75,15 +123,29 @@ const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
addressPostcode: clinic.addressCustom?.addressPostcode ?? '',
|
||||
phone: clinic.phone?.primaryPhoneNumber ?? '',
|
||||
email: clinic.email?.primaryEmail ?? '',
|
||||
weekdayHours: clinic.weekdayHours ?? '',
|
||||
saturdayHours: clinic.saturdayHours ?? '',
|
||||
sundayHours: clinic.sundayHours ?? '',
|
||||
openDays: toDaySelection(clinic),
|
||||
opensAt: clinic.opensAt ?? null,
|
||||
closesAt: clinic.closesAt ?? null,
|
||||
status: clinic.status ?? 'ACTIVE',
|
||||
walkInAllowed: clinic.walkInAllowed ?? true,
|
||||
onlineBooking: clinic.onlineBooking ?? true,
|
||||
cancellationWindowHours: clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
cancellationWindowHours:
|
||||
clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
arriveEarlyMin: clinic.arriveEarlyMin != null ? String(clinic.arriveEarlyMin) : '',
|
||||
requiredDocuments: clinic.requiredDocuments ?? '',
|
||||
requiredDocumentTypes:
|
||||
clinic.clinicRequiredDocuments?.edges
|
||||
.map((e) => e.node.documentType)
|
||||
.filter((t): t is DocumentType => t !== null) ?? [],
|
||||
holidays:
|
||||
clinic.holidays?.edges
|
||||
.filter((e) => e.node.date) // date is required on create but platform may have nulls from earlier
|
||||
.map(
|
||||
(e): ClinicHolidayEntry => ({
|
||||
id: e.node.id,
|
||||
date: e.node.date ?? '',
|
||||
label: e.node.reasonLabel ?? '',
|
||||
}),
|
||||
) ?? [],
|
||||
});
|
||||
|
||||
const statusLabel: Record<ClinicStatus, string> = {
|
||||
@@ -98,17 +160,69 @@ const statusColor: Record<ClinicStatus, 'success' | 'warning' | 'gray'> = {
|
||||
PERMANENTLY_CLOSED: 'gray',
|
||||
};
|
||||
|
||||
// Save-flow helpers — each mutation is a thin wrapper so handleSave
|
||||
// reads linearly.
|
||||
const createClinicMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const updateClinicMutation = (id: string, data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ updateClinic: { id: string } }>(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id, data },
|
||||
);
|
||||
|
||||
const createHolidayMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteHolidayMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteHoliday($id: UUID!) { deleteHoliday(id: $id) { id } }`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
const createRequiredDocMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteRequiredDocMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteClinicRequiredDocument($id: UUID!) {
|
||||
deleteClinicRequiredDocument(id: $id) { id }
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
// -- Page --------------------------------------------------------------------
|
||||
|
||||
export const ClinicsPage = () => {
|
||||
const [clinics, setClinics] = useState<Clinic[]>([]);
|
||||
const [clinics, setClinics] = useState<ClinicNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Clinic | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<ClinicNode | null>(null);
|
||||
const [formValues, setFormValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: Clinic }[] } }>(CLINICS_QUERY);
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: ClinicNode }[] } }>(
|
||||
CLINICS_QUERY,
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
} catch {
|
||||
// toast already shown by apiClient
|
||||
@@ -127,7 +241,7 @@ export const ClinicsPage = () => {
|
||||
setSlideoutOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (clinic: Clinic) => {
|
||||
const handleEdit = (clinic: ClinicNode) => {
|
||||
setEditTarget(clinic);
|
||||
setFormValues(toFormValues(clinic));
|
||||
setSlideoutOpen(true);
|
||||
@@ -140,27 +254,47 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const input = clinicFormToGraphQLInput(formValues);
|
||||
const coreInput = clinicCoreToGraphQLInput(formValues);
|
||||
|
||||
// 1. Upsert the clinic itself.
|
||||
let clinicId: string;
|
||||
if (editTarget) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id: editTarget.id, data: input },
|
||||
);
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
await updateClinicMutation(editTarget.id, coreInput);
|
||||
clinicId = editTarget.id;
|
||||
} else {
|
||||
await apiClient.graphql(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
const res = await createClinicMutation(coreInput);
|
||||
clinicId = res.createClinic.id;
|
||||
notify.success('Clinic added', `${formValues.clinicName} has been added.`);
|
||||
// First clinic added unblocks the wizard's clinics step. Failures
|
||||
// here are silent — the badge will just be stale until next load.
|
||||
markSetupStepComplete('clinics').catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Holidays — delete-all-recreate. Simple, always correct.
|
||||
if (editTarget?.holidays?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.holidays.edges.map((e) => deleteHolidayMutation(e.node.id)),
|
||||
);
|
||||
}
|
||||
if (formValues.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(holidayInputs.map((data) => createHolidayMutation(data)));
|
||||
}
|
||||
|
||||
// 3. Required docs — delete-all-recreate for symmetry.
|
||||
if (editTarget?.clinicRequiredDocuments?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.clinicRequiredDocuments.edges.map((e) =>
|
||||
deleteRequiredDocMutation(e.node.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (formValues.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(docInputs.map((data) => createRequiredDocMutation(data)));
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
}
|
||||
await fetchClinics();
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -170,7 +304,10 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = useMemo(() => clinics.filter((c) => c.status === 'ACTIVE').length, [clinics]);
|
||||
const activeCount = useMemo(
|
||||
() => clinics.filter((c) => c.status === 'ACTIVE').length,
|
||||
[clinics],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -231,6 +368,11 @@ export const ClinicsPage = () => {
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const status = clinic.status ?? 'ACTIVE';
|
||||
const dayLabel = formatDaySelection(toDaySelection(clinic));
|
||||
const hoursLabel =
|
||||
clinic.opensAt && clinic.closesAt
|
||||
? `${formatTimeLabel(clinic.opensAt)}–${formatTimeLabel(clinic.closesAt)}`
|
||||
: 'Not set';
|
||||
return (
|
||||
<Table.Row id={clinic.id}>
|
||||
<Table.Cell>
|
||||
@@ -252,9 +394,10 @@ export const ClinicsPage = () => {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">
|
||||
{clinic.weekdayHours ?? 'Not set'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-primary">{dayLabel}</span>
|
||||
<span className="text-xs text-tertiary">{hoursLabel}</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor[status]} type="pill-color">
|
||||
|
||||
@@ -8,36 +8,54 @@ import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-m
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
DoctorForm,
|
||||
doctorFormToGraphQLInput,
|
||||
doctorCoreToGraphQLInput,
|
||||
visitSlotInputsFromForm,
|
||||
emptyDoctorFormValues,
|
||||
type DoctorDepartment,
|
||||
type DoctorFormValues,
|
||||
type DayOfWeek,
|
||||
type DoctorVisitSlotEntry,
|
||||
} from '@/components/forms/doctor-form';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/doctors — list + add/edit slideout. Loads clinics in parallel to
|
||||
// populate the clinic dropdown in the form. If there are no clinics yet, the
|
||||
// form's clinic select is disabled and the CTA copy steers the admin back to
|
||||
// /settings/clinics first.
|
||||
// /settings/doctors — list + add/edit slideout. Doctors are hospital-
|
||||
// wide; their multi-clinic visiting schedule is modelled as a list of
|
||||
// DoctorVisitSlot child records fetched through the reverse relation.
|
||||
//
|
||||
// Save flow mirrors clinics.tsx: create/update the core doctor row,
|
||||
// then delete-all-recreate the visit slots. Slots are recreated in
|
||||
// parallel (Promise.all). Pre-existing slots from the fetched record
|
||||
// get their id propagated into form state so edit mode shows the
|
||||
// current schedule.
|
||||
|
||||
type Doctor = {
|
||||
type DoctorNode = {
|
||||
id: string;
|
||||
fullName: { firstName: string | null; lastName: string | null } | null;
|
||||
department: DoctorDepartment | null;
|
||||
specialty: string | null;
|
||||
qualifications: string | null;
|
||||
yearsOfExperience: number | null;
|
||||
visitingHours: string | null;
|
||||
consultationFeeNew: { amountMicros: number | null; currencyCode: string | null } | null;
|
||||
consultationFeeFollowUp: { amountMicros: number | null; currencyCode: string | null } | null;
|
||||
phone: { primaryPhoneNumber: string | null } | null;
|
||||
email: { primaryEmail: string | null } | null;
|
||||
registrationNumber: string | null;
|
||||
active: boolean | null;
|
||||
clinicId: string | null;
|
||||
clinic: { id: string; clinicName: string | null } | null;
|
||||
// Reverse-side relation to DoctorVisitSlot records.
|
||||
doctorVisitSlots?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string;
|
||||
clinicId: string | null;
|
||||
clinic: { id: string; clinicName: string | null } | null;
|
||||
dayOfWeek: DayOfWeek | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type ClinicLite = { id: string; clinicName: string | null };
|
||||
@@ -49,15 +67,24 @@ const DOCTORS_QUERY = `{
|
||||
id
|
||||
fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
consultationFeeFollowUp { amountMicros currencyCode }
|
||||
phone { primaryPhoneNumber }
|
||||
email { primaryEmail }
|
||||
registrationNumber
|
||||
active
|
||||
clinicId
|
||||
clinic { id clinicName }
|
||||
doctorVisitSlots(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
clinicId
|
||||
clinic { id clinicName }
|
||||
dayOfWeek
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,15 +102,23 @@ const departmentLabel: Record<DoctorDepartment, string> = {
|
||||
ONCOLOGY: 'Oncology',
|
||||
};
|
||||
|
||||
const toFormValues = (doctor: Doctor): DoctorFormValues => ({
|
||||
const dayLabel: Record<DayOfWeek, string> = {
|
||||
MONDAY: 'Mon',
|
||||
TUESDAY: 'Tue',
|
||||
WEDNESDAY: 'Wed',
|
||||
THURSDAY: 'Thu',
|
||||
FRIDAY: 'Fri',
|
||||
SATURDAY: 'Sat',
|
||||
SUNDAY: 'Sun',
|
||||
};
|
||||
|
||||
const toFormValues = (doctor: DoctorNode): DoctorFormValues => ({
|
||||
firstName: doctor.fullName?.firstName ?? '',
|
||||
lastName: doctor.fullName?.lastName ?? '',
|
||||
department: doctor.department ?? '',
|
||||
specialty: doctor.specialty ?? '',
|
||||
qualifications: doctor.qualifications ?? '',
|
||||
yearsOfExperience: doctor.yearsOfExperience != null ? String(doctor.yearsOfExperience) : '',
|
||||
clinicId: doctor.clinicId ?? '',
|
||||
visitingHours: doctor.visitingHours ?? '',
|
||||
consultationFeeNew: doctor.consultationFeeNew?.amountMicros
|
||||
? String(Math.round(doctor.consultationFeeNew.amountMicros / 1_000_000))
|
||||
: '',
|
||||
@@ -94,6 +129,16 @@ const toFormValues = (doctor: Doctor): DoctorFormValues => ({
|
||||
email: doctor.email?.primaryEmail ?? '',
|
||||
registrationNumber: doctor.registrationNumber ?? '',
|
||||
active: doctor.active ?? true,
|
||||
visitSlots:
|
||||
doctor.doctorVisitSlots?.edges.map(
|
||||
(e): DoctorVisitSlotEntry => ({
|
||||
id: e.node.id,
|
||||
clinicId: e.node.clinicId ?? e.node.clinic?.id ?? '',
|
||||
dayOfWeek: e.node.dayOfWeek ?? '',
|
||||
startTime: e.node.startTime ?? null,
|
||||
endTime: e.node.endTime ?? null,
|
||||
}),
|
||||
) ?? [],
|
||||
});
|
||||
|
||||
const formatFee = (money: { amountMicros: number | null } | null): string => {
|
||||
@@ -101,19 +146,79 @@ const formatFee = (money: { amountMicros: number | null } | null): string => {
|
||||
return `₹${Math.round(money.amountMicros / 1_000_000).toLocaleString('en-IN')}`;
|
||||
};
|
||||
|
||||
// Compact "clinics + days" summary for the list row. Groups by clinic
|
||||
// so "Koramangala: Mon Wed / Whitefield: Tue Thu Fri" is one string.
|
||||
const summariseVisitSlots = (
|
||||
doctor: DoctorNode,
|
||||
clinicNameById: Map<string, string>,
|
||||
): string => {
|
||||
const edges = doctor.doctorVisitSlots?.edges ?? [];
|
||||
if (edges.length === 0) return 'No slots';
|
||||
const byClinic = new Map<string, DayOfWeek[]>();
|
||||
for (const e of edges) {
|
||||
const cid = e.node.clinicId ?? e.node.clinic?.id;
|
||||
if (!cid || !e.node.dayOfWeek) continue;
|
||||
if (!byClinic.has(cid)) byClinic.set(cid, []);
|
||||
byClinic.get(cid)!.push(e.node.dayOfWeek);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const [cid, days] of byClinic.entries()) {
|
||||
const name = clinicNameById.get(cid) ?? 'Unknown clinic';
|
||||
const dayStr = days.map((d) => dayLabel[d]).join(' ');
|
||||
parts.push(`${name}: ${dayStr}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
// -- Mutation helpers --------------------------------------------------------
|
||||
|
||||
const createDoctorMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ createDoctor: { id: string } }>(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const updateDoctorMutation = (id: string, data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ updateDoctor: { id: string } }>(
|
||||
`mutation UpdateDoctor($id: UUID!, $data: DoctorUpdateInput!) {
|
||||
updateDoctor(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id, data },
|
||||
);
|
||||
|
||||
const createVisitSlotMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
|
||||
createDoctorVisitSlot(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteVisitSlotMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteDoctorVisitSlot($id: UUID!) {
|
||||
deleteDoctorVisitSlot(id: $id) { id }
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
// -- Page --------------------------------------------------------------------
|
||||
|
||||
export const DoctorsPage = () => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [doctors, setDoctors] = useState<DoctorNode[]>([]);
|
||||
const [clinics, setClinics] = useState<ClinicLite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Doctor | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<DoctorNode | null>(null);
|
||||
const [formValues, setFormValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
doctors: { edges: { node: Doctor }[] };
|
||||
doctors: { edges: { node: DoctorNode }[] };
|
||||
clinics: { edges: { node: ClinicLite }[] };
|
||||
}>(DOCTORS_QUERY);
|
||||
setDoctors(data.doctors.edges.map((e) => e.node));
|
||||
@@ -152,7 +257,7 @@ export const DoctorsPage = () => {
|
||||
setSlideoutOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (doctor: Doctor) => {
|
||||
const handleEdit = (doctor: DoctorNode) => {
|
||||
setEditTarget(doctor);
|
||||
setFormValues(toFormValues(doctor));
|
||||
setSlideoutOpen(true);
|
||||
@@ -165,25 +270,36 @@ export const DoctorsPage = () => {
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const input = doctorFormToGraphQLInput(formValues);
|
||||
const coreInput = doctorCoreToGraphQLInput(formValues);
|
||||
|
||||
// 1. Upsert doctor
|
||||
let doctorId: string;
|
||||
if (editTarget) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateDoctor($id: UUID!, $data: DoctorUpdateInput!) {
|
||||
updateDoctor(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id: editTarget.id, data: input },
|
||||
);
|
||||
notify.success('Doctor updated', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
await updateDoctorMutation(editTarget.id, coreInput);
|
||||
doctorId = editTarget.id;
|
||||
} else {
|
||||
await apiClient.graphql(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
const res = await createDoctorMutation(coreInput);
|
||||
doctorId = res.createDoctor.id;
|
||||
notify.success('Doctor added', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
markSetupStepComplete('doctors').catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Visit slots — delete-all-recreate, same pattern as
|
||||
// clinics.tsx holidays/requiredDocs. Simple, always correct,
|
||||
// acceptable overhead at human-edit frequency.
|
||||
if (editTarget?.doctorVisitSlots?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.doctorVisitSlots.edges.map((e) => deleteVisitSlotMutation(e.node.id)),
|
||||
);
|
||||
}
|
||||
const slotInputs = visitSlotInputsFromForm(formValues, doctorId);
|
||||
if (slotInputs.length > 0) {
|
||||
await Promise.all(slotInputs.map((data) => createVisitSlotMutation(data)));
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
notify.success('Doctor updated', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
}
|
||||
await fetchAll();
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -242,7 +358,7 @@ export const DoctorsPage = () => {
|
||||
<Table.Header>
|
||||
<Table.Head label="DOCTOR" isRowHeader />
|
||||
<Table.Head label="DEPARTMENT" />
|
||||
<Table.Head label="CLINIC" />
|
||||
<Table.Head label="VISITING SCHEDULE" />
|
||||
<Table.Head label="FEE (NEW)" />
|
||||
<Table.Head label="STATUS" />
|
||||
<Table.Head label="" />
|
||||
@@ -252,10 +368,6 @@ export const DoctorsPage = () => {
|
||||
const firstName = doctor.fullName?.firstName ?? '';
|
||||
const lastName = doctor.fullName?.lastName ?? '';
|
||||
const name = `Dr. ${firstName} ${lastName}`.trim();
|
||||
const clinicName =
|
||||
doctor.clinic?.clinicName ??
|
||||
(doctor.clinicId ? clinicNameById.get(doctor.clinicId) : null) ??
|
||||
'—';
|
||||
return (
|
||||
<Table.Row id={doctor.id}>
|
||||
<Table.Cell>
|
||||
@@ -272,7 +384,9 @@ export const DoctorsPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary">{clinicName}</span>
|
||||
<span className="text-xs text-tertiary">
|
||||
{summariseVisitSlots(doctor, clinicNameById)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary">
|
||||
@@ -323,7 +437,7 @@ export const DoctorsPage = () => {
|
||||
</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{editTarget
|
||||
? 'Update clinician details and clinic assignment'
|
||||
? 'Update clinician details and visiting schedule'
|
||||
: 'Add a new clinician to your hospital'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -80,14 +80,13 @@ export const SetupWizardPage = () => {
|
||||
const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null;
|
||||
|
||||
const handleComplete = async (step: SetupStepName) => {
|
||||
try {
|
||||
const updated = await markSetupStepComplete(step, user?.email);
|
||||
setState(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to mark step complete', err);
|
||||
// Non-fatal — the step's own save already succeeded. We just
|
||||
// couldn't persist the wizard-state badge.
|
||||
}
|
||||
// No try/catch here — if the setup-state PUT fails, we WANT
|
||||
// the error to propagate up to the step's handleSave so the
|
||||
// agent sees a toast AND the advance flow pauses until the
|
||||
// issue is resolved. Silent swallowing hid the real failure
|
||||
// mode during the Ramaiah local test.
|
||||
const updated = await markSetupStepComplete(step, user?.email);
|
||||
setState(updated);
|
||||
};
|
||||
|
||||
const handleAdvance = () => {
|
||||
|
||||
@@ -9,25 +9,21 @@ import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
InviteMemberForm,
|
||||
type InviteMemberFormValues,
|
||||
EmployeeCreateForm,
|
||||
emptyEmployeeCreateFormValues,
|
||||
type EmployeeCreateFormValues,
|
||||
type RoleOption,
|
||||
} from '@/components/forms/invite-member-form';
|
||||
} from '@/components/forms/employee-create-form';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { getInitials } from '@/lib/format';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/team — Phase 3 rewrite. The Phase 2 version was read-only and
|
||||
// inferred roles from the email string; this version:
|
||||
// 1. Fetches real roles via getRoles (platform mutation).
|
||||
// 2. Fetches workspace members WITH their role assignments so the listing
|
||||
// shows actual roles.
|
||||
// 3. Lets admins invite via sendInvitations (multi-email).
|
||||
// 4. Lets admins change a member's role via updateWorkspaceMemberRole.
|
||||
//
|
||||
// Invitations mark the setup-state `team` step complete on first success so
|
||||
// the wizard can advance.
|
||||
// /settings/team — in-place employee management. Fetches real roles + SIP
|
||||
// seats from the platform and uses the sidecar's /api/team/members
|
||||
// endpoint to create workspace members directly with a temp password
|
||||
// that the admin hands out (no email invitations — see
|
||||
// feedback-no-invites in memory). Also lets admins change a member's
|
||||
// role via updateWorkspaceMemberRole.
|
||||
|
||||
type MemberRole = {
|
||||
id: string;
|
||||
@@ -39,10 +35,22 @@ type WorkspaceMember = {
|
||||
name: { firstName: string; lastName: string } | null;
|
||||
userEmail: string;
|
||||
avatarUrl: string | null;
|
||||
roles: MemberRole[];
|
||||
// Platform returns null (not []) for members with no role assigned —
|
||||
// optional-chain when reading.
|
||||
roles: MemberRole[] | null;
|
||||
};
|
||||
|
||||
const MEMBERS_QUERY = `{
|
||||
type CreatedMemberResponse = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
// Combined query — workspace members + assignable roles. Bundled to
|
||||
// save a round-trip and keep the table consistent across the join.
|
||||
const TEAM_QUERY = `{
|
||||
workspaceMembers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
@@ -54,9 +62,6 @@ const MEMBERS_QUERY = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const ROLES_QUERY = `{
|
||||
getRoles {
|
||||
id
|
||||
label
|
||||
@@ -69,31 +74,32 @@ export const TeamSettingsPage = () => {
|
||||
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [inviteValues, setInviteValues] = useState<InviteMemberFormValues>({ emails: [], roleId: '' });
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createValues, setCreateValues] = useState<EmployeeCreateFormValues>(
|
||||
emptyEmployeeCreateFormValues,
|
||||
);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [memberData, roleData] = await Promise.all([
|
||||
apiClient.graphql<{ workspaceMembers: { edges: { node: WorkspaceMember }[] } }>(
|
||||
MEMBERS_QUERY,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
),
|
||||
apiClient.graphql<{
|
||||
getRoles: { id: string; label: string; description: string | null; canBeAssignedToUsers: boolean }[];
|
||||
}>(ROLES_QUERY, undefined, { silent: true }),
|
||||
]);
|
||||
setMembers(memberData.workspaceMembers.edges.map((e) => e.node));
|
||||
const data = await apiClient.graphql<{
|
||||
workspaceMembers: { edges: { node: WorkspaceMember }[] };
|
||||
getRoles: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
}[];
|
||||
}>(TEAM_QUERY, undefined, { silent: true });
|
||||
|
||||
setMembers(data.workspaceMembers.edges.map((e) => e.node));
|
||||
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
|
||||
setRoles(
|
||||
roleData.getRoles
|
||||
.filter((r) => r.canBeAssignedToUsers)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
assignable.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
// silently fail
|
||||
@@ -150,38 +156,46 @@ export const TeamSettingsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvites = async (close: () => void) => {
|
||||
if (inviteValues.emails.length === 0) {
|
||||
notify.error('Add at least one email');
|
||||
const handleCreateMember = async (close: () => void) => {
|
||||
const firstName = createValues.firstName.trim();
|
||||
const email = createValues.email.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
setIsSending(true);
|
||||
if (!email) {
|
||||
notify.error('Email is required');
|
||||
return;
|
||||
}
|
||||
if (!createValues.password) {
|
||||
notify.error('Temporary password is required');
|
||||
return;
|
||||
}
|
||||
if (!createValues.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) {
|
||||
success
|
||||
errors
|
||||
result {
|
||||
email
|
||||
id
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ emails: inviteValues.emails },
|
||||
);
|
||||
await apiClient.post<CreatedMemberResponse>('/api/team/members', {
|
||||
firstName,
|
||||
lastName: createValues.lastName.trim(),
|
||||
email,
|
||||
password: createValues.password,
|
||||
roleId: createValues.roleId,
|
||||
});
|
||||
notify.success(
|
||||
'Invitations sent',
|
||||
`${inviteValues.emails.length} invitation${inviteValues.emails.length === 1 ? '' : 's'} sent.`,
|
||||
'Employee created',
|
||||
`${firstName} ${createValues.lastName.trim()}`.trim() || email,
|
||||
);
|
||||
markSetupStepComplete('team').catch(() => {});
|
||||
setInviteValues({ emails: [], roleId: '' });
|
||||
setCreateValues(emptyEmployeeCreateFormValues);
|
||||
await fetchData();
|
||||
close();
|
||||
} catch (err) {
|
||||
console.error('[team] invite failed', err);
|
||||
console.error('[team] create failed', err);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,11 +229,11 @@ export const TeamSettingsPage = () => {
|
||||
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
||||
)}
|
||||
onClick={() => {
|
||||
setInviteValues({ emails: [], roleId: '' });
|
||||
setInviteOpen(true);
|
||||
setCreateValues(emptyEmployeeCreateFormValues);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Invite members
|
||||
Add employee
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -244,7 +258,8 @@ export const TeamSettingsPage = () => {
|
||||
const lastName = member.name?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed';
|
||||
const initials = getInitials(firstName || '?', lastName || '?');
|
||||
const currentRoleId = member.roles[0]?.id ?? null;
|
||||
const memberRoles = member.roles ?? [];
|
||||
const currentRoleId = memberRoles[0]?.id ?? null;
|
||||
|
||||
return (
|
||||
<Table.Row id={member.id}>
|
||||
@@ -284,9 +299,9 @@ export const TeamSettingsPage = () => {
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
) : member.roles.length > 0 ? (
|
||||
) : memberRoles.length > 0 ? (
|
||||
<Badge size="sm" color="gray">
|
||||
{member.roles[0].label}
|
||||
{memberRoles[0].label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">No role</span>
|
||||
@@ -344,7 +359,7 @@ export const TeamSettingsPage = () => {
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
|
||||
<SlideoutMenu isOpen={inviteOpen} onOpenChange={setInviteOpen} isDismissable>
|
||||
<SlideoutMenu isOpen={createOpen} onOpenChange={setCreateOpen} isDismissable>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
@@ -353,19 +368,23 @@ export const TeamSettingsPage = () => {
|
||||
<FontAwesomeIcon icon={faUserPlus} className="size-5 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">Invite members</h2>
|
||||
<h2 className="text-lg font-semibold text-primary">Add employee</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Send invitations to supervisors, agents, and doctors
|
||||
Create supervisors, CC agents and admins in place
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<InviteMemberForm value={inviteValues} onChange={setInviteValues} roles={roles} />
|
||||
<p className="text-xs text-tertiary">
|
||||
Invitees receive an email with a link to set their password. Role assignment can be changed
|
||||
from the employees table after they accept.
|
||||
<EmployeeCreateForm
|
||||
value={createValues}
|
||||
onChange={setCreateValues}
|
||||
roles={roles}
|
||||
/>
|
||||
<p className="mt-4 text-xs text-tertiary">
|
||||
The employee logs in with this email and the temporary password
|
||||
you set. Share both with them directly — no email is sent.
|
||||
</p>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
@@ -377,16 +396,11 @@ export const TeamSettingsPage = () => {
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={isSending}
|
||||
isLoading={isCreating}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSendInvites(close)}
|
||||
isDisabled={inviteValues.emails.length === 0}
|
||||
onClick={() => handleCreateMember(close)}
|
||||
>
|
||||
{isSending
|
||||
? 'Sending...'
|
||||
: `Send ${inviteValues.emails.length || ''} invitation${
|
||||
inviteValues.emails.length === 1 ? '' : 's'
|
||||
}`.trim()}
|
||||
{isCreating ? 'Creating…' : 'Create employee'}
|
||||
</Button>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
|
||||
Reference in New Issue
Block a user