mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: telephony overhaul + appointment availability + Force Ready
Telephony: - Track UCID from SIP headers and ManualDial response - Submit disposition to Ozonetel via Set Disposition API (ends ACW) - Fix outboundPending flag lifecycle to prevent inbound poisoning - Fix render order: post-call UI takes priority over active state - Pre-select disposition when appointment booked during call Appointment form: - Convert from slideout to inline collapsible below call card - Fetch real doctors from platform, filter by department - Show time slot availability grid (booked slots greyed + strikethrough) - Double-check availability before booking - Support edit and cancel existing appointments UI: - Add Force Ready button to profile menu (logout+login to clear ACW) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
374
docs/superpowers/plans/2026-03-20-appointment-availability.md
Normal file
374
docs/superpowers/plans/2026-03-20-appointment-availability.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Appointment Availability, Edit & Cancel — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add doctor availability checking (grey out booked slots), appointment editing, and appointment cancellation to the inline appointment form.
|
||||
|
||||
**Architecture:** The form queries the platform for real doctors (replacing hardcoded list) and existing appointments for the selected doctor+date. Booked time slots are greyed out. After booking, the agent can edit or cancel via the same inline form. All queries go through the sidecar's GraphQL proxy.
|
||||
|
||||
**Tech Stack:** React 19 + Jotai, platform GraphQL API (Doctor + Appointment entities)
|
||||
|
||||
**Platform entities (relevant fields):**
|
||||
- **Doctor**: `id`, `fullName { firstName lastName }`, `department` (SELECT), `specialty`, `visitingHours`, `active`, `clinic { id clinicName }`
|
||||
- **Appointment**: `id`, `scheduledAt` (DATE_TIME), `durationMin` (NUMBER, default 30), `appointmentType` (SELECT), `appointmentStatus` (SELECT: SCHEDULED/CONFIRMED/CANCELLED/...), `doctorName` (TEXT), `department` (TEXT), `reasonForVisit` (TEXT), `patientId` (relation), `doctorId` (relation)
|
||||
|
||||
**GraphQL field name note:** SDK field `durationMinutes` maps to `durationMin` in GraphQL. SDK field `isActive` maps to `active` in GraphQL.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|------|---------------|--------|
|
||||
| `src/components/call-desk/appointment-form.tsx` | Inline appointment form | Modify: fetch real doctors, check availability, support edit/cancel mode |
|
||||
| `src/lib/queries.ts` | GraphQL queries | Modify: add appointment-by-doctor-date query |
|
||||
|
||||
Only 2 files. The form is self-contained — all logic stays in the component.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fetch real doctors from platform
|
||||
|
||||
Replace hardcoded `doctorItems` and `departmentItems` with real data from the platform.
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/components/call-desk/appointment-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Add doctor fetching on mount**
|
||||
|
||||
Import `apiClient` at top level (remove the dynamic import in handleSave). Add state for doctors and a useEffect to fetch them:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
// Inside the component:
|
||||
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string; clinic: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50, filter: { active: { eq: true } }) { edges { node {
|
||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
||||
} } } }`,
|
||||
).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 ?? '',
|
||||
}));
|
||||
setDoctors(docs);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Derive department and doctor lists from fetched data**
|
||||
|
||||
Replace hardcoded `departmentItems` and `doctorItems` with derived lists:
|
||||
|
||||
```typescript
|
||||
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
|
||||
|
||||
// Filter doctors by selected department
|
||||
const filteredDoctors = department
|
||||
? doctors.filter(d => d.department === department)
|
||||
: doctors;
|
||||
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update Select components to use dynamic data**
|
||||
|
||||
The Department select uses `departmentItems` (derived). The Doctor select uses `doctorSelectItems` (filtered by department). Reset doctor selection when department changes.
|
||||
|
||||
- [ ] **Step 4: Update handleSave to use doctor ID**
|
||||
|
||||
Instead of sending `doctorName` as a text label, send the `doctorId` relation:
|
||||
|
||||
```typescript
|
||||
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
createAppointment(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
appointmentStatus: 'SCHEDULED',
|
||||
doctorName: selectedDoctor?.name ?? '',
|
||||
department: selectedDoctor?.department ?? '',
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
doctorId: doctor,
|
||||
...(leadId ? { patientId: leadId } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove hardcoded doctorItems and departmentItems arrays**
|
||||
|
||||
Delete the `const doctorItems = [...]` and `const departmentItems = [...]` arrays from the top of the file.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat: fetch real doctors from platform for appointment form
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add slot availability checking
|
||||
|
||||
When the agent selects a doctor and date, query existing appointments and grey out booked slots.
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/components/call-desk/appointment-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Add booked slots state and fetch logic**
|
||||
|
||||
```typescript
|
||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) {
|
||||
setBookedSlots([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSlots(true);
|
||||
const dayStart = `${date}T00:00:00`;
|
||||
const dayEnd = `${date}T23:59:59`;
|
||||
|
||||
apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${dayStart}", lte: "${dayEnd}" },
|
||||
appointmentStatus: { in: [SCHEDULED, CONFIRMED, CHECKED_IN, IN_PROGRESS] }
|
||||
}) { edges { node { id scheduledAt durationMin } } } }`,
|
||||
).then(data => {
|
||||
const slots = data.appointments.edges.map(e => {
|
||||
const dt = new Date(e.node.scheduledAt);
|
||||
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
setBookedSlots(slots);
|
||||
}).catch(() => {
|
||||
setBookedSlots([]);
|
||||
}).finally(() => setLoadingSlots(false));
|
||||
}, [doctor, date]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Grey out booked slots in the time slot Select**
|
||||
|
||||
Mark booked slots as disabled in the Select items:
|
||||
|
||||
```typescript
|
||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||
...slot,
|
||||
isDisabled: bookedSlots.includes(slot.id),
|
||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||
}));
|
||||
```
|
||||
|
||||
Use `timeSlotSelectItems` in the Time Slot Select component. Clear selected timeSlot when doctor or date changes if the slot becomes booked.
|
||||
|
||||
- [ ] **Step 3: Add double-check before save**
|
||||
|
||||
In `handleSave`, before creating the appointment, re-query to confirm the slot is still available:
|
||||
|
||||
```typescript
|
||||
// Double-check slot availability before booking
|
||||
const dayStart = `${date}T00:00:00`;
|
||||
const dayEnd = `${date}T23:59:59`;
|
||||
const checkResult = await apiClient.graphql<{ appointments: { totalCount: number } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${dayStart}T${timeSlot}:00", lte: "${dayStart}T${timeSlot}:00" },
|
||||
appointmentStatus: { in: [SCHEDULED, CONFIRMED] }
|
||||
}) { totalCount } }`,
|
||||
);
|
||||
if (checkResult.appointments.totalCount > 0) {
|
||||
setError('This slot was just booked by someone else. Please select a different time.');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: check doctor availability and grey out booked time slots
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add edit and cancel support
|
||||
|
||||
After an appointment is booked (via the disposition flow's APPOINTMENT_BOOKED path), allow the agent to edit or cancel it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/components/call-desk/appointment-form.tsx`
|
||||
|
||||
- [ ] **Step 1: Add optional appointment prop for edit mode**
|
||||
|
||||
Extend the props type:
|
||||
|
||||
```typescript
|
||||
type AppointmentFormProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
callerNumber?: string | null;
|
||||
leadName?: string | null;
|
||||
leadId?: string | null;
|
||||
onSaved?: () => void;
|
||||
existingAppointment?: {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
doctorName: string;
|
||||
doctorId?: string;
|
||||
department: string;
|
||||
reasonForVisit?: string;
|
||||
appointmentStatus: string;
|
||||
} | null;
|
||||
};
|
||||
```
|
||||
|
||||
When `existingAppointment` is provided, pre-fill the form fields from it. Change the header to "Edit Appointment" and the save button to "Update Appointment".
|
||||
|
||||
- [ ] **Step 2: Initialize form state from existing appointment**
|
||||
|
||||
```typescript
|
||||
const isEditMode = !!existingAppointment;
|
||||
|
||||
// Initialize state from existing appointment if in edit mode
|
||||
const [date, setDate] = useState(() => {
|
||||
if (existingAppointment?.scheduledAt) {
|
||||
return existingAppointment.scheduledAt.split('T')[0];
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
||||
if (existingAppointment?.scheduledAt) {
|
||||
const dt = new Date(existingAppointment.scheduledAt);
|
||||
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add update mutation to handleSave**
|
||||
|
||||
In `handleSave`, if `isEditMode`, use `updateAppointment` instead of `createAppointment`:
|
||||
|
||||
```typescript
|
||||
if (isEditMode && existingAppointment) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: existingAppointment.id,
|
||||
data: {
|
||||
scheduledAt,
|
||||
doctorName: selectedDoctor?.name ?? '',
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// existing create logic
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add Cancel Appointment button**
|
||||
|
||||
In edit mode, add a "Cancel Appointment" button in the footer:
|
||||
|
||||
```typescript
|
||||
{isEditMode && (
|
||||
<Button size="sm" color="primary-destructive" onClick={handleCancel}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
The handler:
|
||||
|
||||
```typescript
|
||||
const handleCancel = async () => {
|
||||
if (!existingAppointment) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: existingAppointment.id,
|
||||
data: { appointmentStatus: 'CANCELLED' },
|
||||
},
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Import `notify` from `@/lib/toast`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: add appointment edit and cancel support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Deploy and verify
|
||||
|
||||
- [ ] **Step 1: Type check**
|
||||
|
||||
```bash
|
||||
cd helix-engage && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and deploy frontend**
|
||||
|
||||
```bash
|
||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
|
||||
VITE_SIP_PASSWORD=523590 \
|
||||
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test availability checking**
|
||||
|
||||
1. During a call, click "Book Appt"
|
||||
2. Select a department → doctor list filters
|
||||
3. Select a doctor + date → booked slots show as "(Booked)" and are disabled
|
||||
4. Select an available slot → "Book Appointment" succeeds
|
||||
5. Select the same doctor + date again → the slot you just booked should now be greyed out
|
||||
|
||||
- [ ] **Step 4: Test edit/cancel**
|
||||
|
||||
This requires passing `existingAppointment` prop from the parent component. For now, verify the edit/cancel code compiles and the props interface is correct. Wiring the edit flow from the call history or lead 360 panel can be done as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Hardcoded clinicItems and genderItems stay** — clinics aren't changing often and gender is static. Only doctors and departments come from the platform.
|
||||
- **timeSlotItems stay hardcoded** — these represent the hospital's standard appointment slots (30-min increments, 9am-4pm). The availability check determines which ones are booked.
|
||||
- **The `appointmentStatus` filter uses `in: [SCHEDULED, CONFIRMED, CHECKED_IN, IN_PROGRESS]`** — cancelled and completed appointments don't block the slot.
|
||||
- **The `doctorId` relation** links the appointment to the Doctor entity on the platform. We also keep `doctorName` as a denormalized text field for display.
|
||||
Reference in New Issue
Block a user