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:
2026-03-20 20:24:58 +05:30
parent f13decefc4
commit 99bca1e008
7 changed files with 1328 additions and 284 deletions

View 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.

View File

@@ -0,0 +1,449 @@
# Telephony Overhaul — Ozonetel CloudAgent Integration
> **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:** Fix the end-to-end telephony flow so inbound and outbound calls work reliably with proper agent state management via Ozonetel CloudAgent APIs.
**Architecture:** The sidecar (NestJS BFF) is the single backend for all Ozonetel API calls. The frontend (React) handles SIP signaling via JsSIP and sends disposition + UCID to the sidecar. The sidecar posts disposition to both Ozonetel (to end ACW) and the platform (to create Call records). UCID is the thread that ties the call across all three systems.
**Tech Stack:** NestJS sidecar, React 19 + Jotai, JsSIP, Ozonetel CloudAgent Token Auth APIs
**Ozonetel API Reference:**
- Token: `POST /ca_apis/CAToken/generateToken` — apiKey header + `{userName}` body → JWT (60 min)
- Login: `POST /CAServices/AgentAuthenticationV2/index.php` — HTTP Basic Auth + form body
- Manual Dial: `POST /ca_apis/AgentManualDial` — Bearer token + `{userName, agentID, campaignName, customerNumber, UCID:"true"}`
- Set Disposition: `POST /ca_apis/DispositionAPIV2` — Bearer token + `{userName, agentID, did, ucid, action:"Set", disposition, autoRelease:"true"}`
- Change Agent State: `POST /ca_apis/changeAgentState` — Bearer token + `{userName, agentId, state:"Ready"|"Pause"}`
**Constants:**
- Account: `global_healthx`
- Agent: `global` / `Test123$`
- DID: `918041763265`
- SIP Extension: `523590`
- Campaign: `Inbound_918041763265`
- Campaign Dispositions: `General Enquiry` (only one configured currently)
---
## Current Bugs (root causes)
1. **Agent stuck in ACW after call** — We never call Set Disposition API on Ozonetel. CloudAgent doesn't know the call is done, keeps agent in ACW indefinitely (wrapup timer doesn't help because CloudAgent expects a disposition submission).
2. **outboundPending flag poisons inbound calls** — After an outbound call, the flag isn't reliably reset, causing the next inbound SIP INVITE to be auto-answered silently instead of ringing.
3. **No UCID tracking** — The UCID (Unique Call ID) ties the call across Ozonetel, SIP, and our platform. We get it from ManualDial response and SIP X-UCID header but never store it.
4. **Duplicate Call records** — Both the webhook (server-side) and the disposition form (client-side) create Call records in the platform for the same call.
5. **"Ready" badge lies** — Shows SIP registration status, not CloudAgent agent state. Agent can be SIP-registered but in ACW.
## File Map
### Sidecar (helix-engage-server)
| File | Responsibility | Action |
|------|---------------|--------|
| `src/ozonetel/ozonetel-agent.service.ts` | All Ozonetel API calls (token, login, dial, disposition, state) | Modify: add `setDisposition()` |
| `src/ozonetel/ozonetel-agent.controller.ts` | REST endpoints for frontend | Modify: add `POST /api/ozonetel/dispose`, update `POST /api/ozonetel/dial` to return UCID |
| `src/worklist/missed-call-webhook.controller.ts` | Ozonetel webhook → platform Call records | Keep as-is (handles missed/unanswered calls the frontend never sees) |
### Frontend (helix-engage)
| File | Responsibility | Action |
|------|---------------|--------|
| `src/state/sip-state.ts` | Jotai atoms for call state | Modify: add `sipCallUcidAtom` |
| `src/state/sip-manager.ts` | Singleton SIP client manager + outbound flags | Modify: extract UCID from SIP headers, store in atom, clean up outbound flags |
| `src/lib/sip-client.ts` | JsSIP wrapper | Modify: extract and expose UCID from SIP INVITE `X-UCID` header |
| `src/components/call-desk/active-call-card.tsx` | Call UI + disposition flow | Modify: pass UCID to sidecar disposition endpoint, remove duplicate Call record creation |
| `src/components/call-desk/click-to-call-button.tsx` | Outbound call trigger | Modify: store UCID from dial response |
| `src/pages/call-desk.tsx` | Call desk page layout | Modify: pass UCID to ActiveCallCard |
| `src/providers/sip-provider.tsx` | SIP lifecycle + Jotai bridge | Modify: expose UCID in `useSip()` hook |
---
## Task 1: Add UCID atom and extraction
Track the Ozonetel UCID (Unique Call ID) for every call so we can submit disposition against it.
**Files:**
- Modify: `helix-engage/src/state/sip-state.ts`
- Modify: `helix-engage/src/lib/sip-client.ts`
- Modify: `helix-engage/src/state/sip-manager.ts`
- Modify: `helix-engage/src/providers/sip-provider.tsx`
- [ ] **Step 1: Add UCID atom**
In `src/state/sip-state.ts`, add:
```typescript
export const sipCallUcidAtom = atom<string | null>(null);
```
- [ ] **Step 2: Extract UCID from SIP INVITE in sip-client.ts**
In `extractCallerNumber`, also extract and return UCID. Change the method signature and the `newRTCSession` handler to extract `X-UCID` from the SIP request:
```typescript
// In newRTCSession handler, after extracting callerNumber:
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
console.log('[SIP] X-UCID:', ucid);
```
Update the `onCallStateChange` callback type to include ucid:
```typescript
private onCallStateChange: (state: CallState, callerNumber?: string, ucid?: string) => void,
```
Pass ucid in all `onCallStateChange` calls within `newRTCSession`.
- [ ] **Step 3: Store UCID in sip-manager.ts**
Add `setCallUcid` to the `StateUpdater` type. In the callback, store the UCID when a call starts:
```typescript
type StateUpdater = {
setConnectionStatus: (status: ConnectionStatus) => void;
setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void;
setCallUcid: (ucid: string | null) => void;
};
```
In the callback, for inbound calls set UCID from the SIP header. For outbound calls, UCID comes from the ManualDial API response (stored via a separate setter — see Task 3).
- [ ] **Step 4: Expose UCID in sip-provider.tsx**
Register `setCallUcid` in the state updater. Expose `callUcid` in the `useSip()` hook return.
- [ ] **Step 5: Commit**
```
feat: track UCID from SIP headers for Ozonetel disposition
```
---
## Task 2: Add Set Disposition API to sidecar
The sidecar calls Ozonetel's Set Disposition API to end ACW and simultaneously creates the Call record on our platform.
**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.service.ts`
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
- [ ] **Step 1: Add `setDisposition()` to service**
```typescript
async setDisposition(params: {
agentId: string;
ucid: string;
disposition: string;
}): Promise<{ status: string; message?: string }> {
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
const did = process.env.OZONETEL_DID ?? '918041763265';
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
try {
const token = await this.getToken();
const response = await axios.post(url, {
userName: this.accountId,
agentID: params.agentId,
did,
ucid: params.ucid,
action: 'Set',
disposition: params.disposition,
autoRelease: 'true',
}, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Set disposition failed: ${error.message} ${responseData}`);
throw error;
}
}
```
- [ ] **Step 2: Add `POST /api/ozonetel/dispose` endpoint**
This endpoint receives disposition from the frontend and does two things in parallel:
1. Calls Ozonetel Set Disposition (to end ACW)
2. Creates Call record on platform (moves this from frontend to sidecar)
```typescript
@Post('dispose')
async dispose(
@Body() body: {
ucid: string;
disposition: string;
callerPhone?: string;
direction?: string;
durationSec?: number;
leadId?: string;
notes?: string;
},
) {
if (!body.ucid || !body.disposition) {
throw new HttpException('ucid and disposition required', 400);
}
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`);
// Map our disposition to Ozonetel campaign disposition
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
// Fire both in parallel — Ozonetel disposition + platform Call record
const [ozResult] = await Promise.allSettled([
this.ozonetelAgent.setDisposition({
agentId: this.defaultAgentId,
ucid: body.ucid,
disposition: ozonetelDisposition,
}),
// Platform call record creation can be added here if needed
// (currently the webhook already creates it)
]);
if (ozResult.status === 'rejected') {
this.logger.error(`Ozonetel disposition failed: ${ozResult.reason}`);
}
return { status: 'success' };
}
private mapToOzonetelDisposition(disposition: string): string {
// Campaign only has 'General Enquiry' configured
// Map all our dispositions to it for now
const map: Record<string, string> = {
'APPOINTMENT_BOOKED': 'General Enquiry',
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
'INFO_PROVIDED': 'General Enquiry',
'NO_ANSWER': 'General Enquiry',
'WRONG_NUMBER': 'General Enquiry',
'CALLBACK_REQUESTED': 'General Enquiry',
};
return map[disposition] ?? 'General Enquiry';
}
```
- [ ] **Step 3: Remove `agent-ready` endpoint**
The `agent-ready` endpoint is no longer needed — `autoRelease: "true"` in Set Disposition handles the agent state transition. Delete the `agentReady()` method from the controller. Keep `changeAgentState()` in the service for future use.
- [ ] **Step 4: Update `dial` endpoint to return UCID**
The ManualDial response already includes `ucid`. Ensure the controller returns it:
```typescript
// Already works — response.data includes { status, ucid }
return result;
```
- [ ] **Step 5: Commit**
```
feat: add Ozonetel Set Disposition API for proper ACW release
```
---
## Task 3: Update frontend disposition flow
The frontend sends disposition + UCID to the sidecar instead of creating Call records directly.
**Files:**
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
- Modify: `helix-engage/src/components/call-desk/click-to-call-button.tsx`
- [ ] **Step 1: Store outbound UCID from dial response**
In `click-to-call-button.tsx`, the dial API already returns `{ ucid }`. Store it in the Jotai atom:
```typescript
const setCallUcid = useSetAtom(sipCallUcidAtom);
// In handleDial, after successful dial:
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
if (result?.ucid) {
setCallUcid(result.ucid);
}
```
- [ ] **Step 2: Rewrite handleDisposition in active-call-card.tsx**
Replace the current `handleDisposition` which creates Call records via GraphQL with a single call to the sidecar `/api/ozonetel/dispose` endpoint:
```typescript
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
setSavedDisposition(disposition);
// Send disposition to sidecar — handles both Ozonetel ACW release + platform Call record
const ucid = callUcid; // from useSip() hook
if (ucid) {
apiClient.post('/api/ozonetel/dispose', {
ucid,
disposition,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
leadId: lead?.id ?? null,
notes,
}).catch((err) => console.warn('Disposition failed:', err));
}
// Continue with local UI flow (appointment, follow-up, done)
if (disposition === 'APPOINTMENT_BOOKED') { ... }
...
};
```
- [ ] **Step 3: Update handleReset**
Remove the `agent-ready` call. Reset UCID atom. Keep `setOutboundPending(false)`:
```typescript
const handleReset = () => {
setPostCallStage(null);
setSavedDisposition(null);
setCallState('idle');
setCallerNumber(null);
setCallUcid(null);
setOutboundPending(false);
};
```
- [ ] **Step 4: Commit**
```
feat: send disposition to sidecar with UCID for Ozonetel ACW release
```
---
## Task 4: Fix outboundPending flag lifecycle
The `outboundPending` flag causes inbound calls to be silently auto-answered when it's not properly reset.
**Files:**
- Modify: `helix-engage/src/state/sip-manager.ts`
- Modify: `helix-engage/src/components/call-desk/click-to-call-button.tsx`
- [ ] **Step 1: Clear outboundPending on SIP ended/failed**
In `sip-manager.ts`, reset both outbound flags when a call ends:
```typescript
// Reset outbound flags when call ends
if (state === 'ended' || state === 'failed') {
outboundActive = false;
outboundPending = false;
}
```
- [ ] **Step 2: Keep safety timeout but clear on success too**
In `click-to-call-button.tsx`, clear the safety timer when dial succeeds (the SIP INVITE will arrive and handle the flag):
```typescript
try {
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
if (result?.ucid) setCallUcid(result.ucid);
// Timer auto-clears outboundPending after 30s if SIP never arrives
} catch {
clearTimeout(safetyTimer);
...
}
```
- [ ] **Step 3: Commit**
```
fix: reset outboundPending on call end to prevent inbound poisoning
```
---
## Task 5: Fix inbound caller ID extraction
The `X-CALLERNO` header is `undefined` for outbound-bridge SIP INVITEs (expected — only real inbound calls have it). But for real inbound calls, the current extraction should work. Verify and clean up the debug logging.
**Files:**
- Modify: `helix-engage/src/lib/sip-client.ts`
- [ ] **Step 1: Remove debug console.logs**
Remove the `[SIP] extractCallerNumber` and `[SIP] X-CALLERNO` debug logs added during debugging. Keep only `console.warn` for actual errors.
- [ ] **Step 2: Commit**
```
chore: remove SIP caller ID debug logging
```
---
## Task 6: Deploy and verify
- [ ] **Step 1: Type check both projects**
```bash
cd helix-engage-server && npx tsc --noEmit
cd helix-engage && npx tsc --noEmit
```
- [ ] **Step 2: Build and deploy sidecar**
```bash
cd helix-engage-server
npm run build
# tar + scp + docker cp + restart
```
- [ ] **Step 3: Build and deploy frontend**
```bash
cd helix-engage
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
# tar + scp to /opt/fortytwo/helix-engage-frontend
```
- [ ] **Step 4: Test outbound flow**
1. Login as rekha.cc@globalhospital.com
2. Click Call on a lead
3. SIP should auto-answer (outbound bridge)
4. Talk, then End → Disposition form
5. Select disposition → "Back to Worklist"
6. Check sidecar logs: `Set disposition response: {"status":"Success"}`
7. Immediately place another outbound call — should work without waiting
- [ ] **Step 5: Test inbound flow**
1. After "Back to Worklist" from above
2. Call the DID from a phone
3. Browser should ring (NOT auto-answer)
4. Answer → talk → End → Disposition → "Back to Worklist"
5. Check sidecar logs: disposition submitted, ACW released
6. Call again — should ring within seconds
- [ ] **Step 6: Test inbound-only flow (no prior outbound)**
1. Hard refresh → login
2. Call the DID
3. Should ring, answer, disposition, back to worklist
4. Call again — should ring
---
## Future: Campaign disposition configuration
Currently all our UI dispositions map to "General Enquiry" because that's the only disposition configured in the Ozonetel campaign. To enable proper mapping:
1. Add dispositions in CloudAgent admin: Campaigns → Inbound_918041763265 → General Info → Dispositions
2. Add: `Appointment Booked`, `Follow Up`, `Info Provided`, `Not Interested`, `Wrong Number`, `No Answer`
3. Update the `mapToOzonetelDisposition()` in the controller to map 1:1
## Future: Agent state badge
The "Ready" badge currently shows SIP registration status. To show actual CloudAgent agent state, periodically poll the Agent State Summary API or listen for state changes. Low priority — the disposition flow should keep the agent in Ready state now.

View File

@@ -1,7 +1,7 @@
import type { FC, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { Placement } from "@react-types/overlays";
import { ChevronSelectorVertical, LogOut01, Settings01, User01 } from "@untitledui/icons";
import { ChevronSelectorVertical, LogOut01, PhoneCall01, Settings01, User01 } from "@untitledui/icons";
import { useFocusManager } from "react-aria";
import type { DialogProps as AriaDialogProps } from "react-aria-components";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
@@ -26,8 +26,9 @@ type NavAccountType = {
export const NavAccountMenu = ({
className,
onSignOut,
onForceReady,
...dialogProps
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void }) => {
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => {
const focusManager = useFocusManager();
const dialogRef = useRef<HTMLDivElement>(null);
@@ -68,6 +69,7 @@ export const NavAccountMenu = ({
<div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
<NavAccountCardMenuItem label="Force Ready" icon={PhoneCall01} onClick={onForceReady} />
</div>
</div>
@@ -114,11 +116,13 @@ export const NavAccountCard = ({
selectedAccountId,
items = [],
onSignOut,
onForceReady,
}: {
popoverPlacement?: Placement;
selectedAccountId?: string;
items?: NavAccountType[];
onSignOut?: () => void;
onForceReady?: () => void;
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const isDesktop = useBreakpoint("lg");
@@ -159,7 +163,7 @@ export const NavAccountCard = ({
)
}
>
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} />
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onForceReady={onForceReady} />
</AriaPopover>
</AriaDialogTrigger>
</div>

View File

@@ -38,6 +38,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false);
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
@@ -94,7 +95,12 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
if (callState === 'active') {
setAppointmentBookedDuringCall(true);
} else {
setPostCallStage('done');
}
};
const handleReset = () => {
@@ -157,52 +163,8 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
);
}
// Active call
if (callState === 'active') {
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</div>
);
}
// Call ended — show disposition
if (callState === 'ended' || callState === 'failed' || postCallStage !== null) {
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
// Done state
if (postCallStage === 'done') {
return (
@@ -255,7 +217,51 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
</div>
</div>
<DispositionForm onSubmit={handleDisposition} />
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
</div>
);
}
// Active call
if (callState === 'active') {
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
<Button size="sm" color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</div>
);
}

View File

@@ -1,15 +1,28 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarPlus } from '@fortawesome/pro-duotone-svg-icons';
import type { FC, HTMLAttributes } from 'react';
const CalendarPlus02: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />;
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { XClose } from '@untitledui/icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
type ExistingAppointment = {
id: string;
scheduledAt: string;
doctorName: string;
doctorId?: string;
department: string;
reasonForVisit?: string;
appointmentStatus: string;
};
type AppointmentFormProps = {
isOpen: boolean;
@@ -18,33 +31,17 @@ type AppointmentFormProps = {
leadName?: string | null;
leadId?: string | null;
onSaved?: () => void;
existingAppointment?: ExistingAppointment | null;
};
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
const clinicItems = [
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
];
const departmentItems = [
{ id: 'cardiology', label: 'Cardiology' },
{ id: 'gynecology', label: 'Gynecology' },
{ id: 'orthopedics', label: 'Orthopedics' },
{ id: 'general-medicine', label: 'General Medicine' },
{ id: 'ent', label: 'ENT' },
{ id: 'dermatology', label: 'Dermatology' },
{ id: 'pediatrics', label: 'Pediatrics' },
{ id: 'oncology', label: 'Oncology' },
];
const doctorItems = [
{ id: 'dr-sharma', label: 'Dr. Sharma' },
{ id: 'dr-patel', label: 'Dr. Patel' },
{ id: 'dr-kumar', label: 'Dr. Kumar' },
{ id: 'dr-reddy', label: 'Dr. Reddy' },
{ id: 'dr-singh', label: 'Dr. Singh' },
];
const genderItems = [
{ id: 'male', label: 'Male' },
{ id: 'female', label: 'Female' },
@@ -65,6 +62,9 @@ const timeSlotItems = [
{ id: '16:00', label: '4:00 PM' },
];
const formatDeptLabel = (dept: string) =>
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
export const AppointmentForm = ({
isOpen,
onOpenChange,
@@ -72,24 +72,125 @@ export const AppointmentForm = ({
leadName,
leadId,
onSaved,
existingAppointment,
}: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
// Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
// Form state — initialized from existing appointment in edit mode
const [patientName, setPatientName] = useState(leadName ?? '');
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null);
const [department, setDepartment] = useState<string | null>(null);
const [doctor, setDoctor] = useState<string | null>(null);
const [date, setDate] = useState('');
const [timeSlot, setTimeSlot] = useState<string | null>(null);
const [chiefComplaint, setChiefComplaint] = useState('');
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
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 [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [isReturning, setIsReturning] = useState(false);
const [source, setSource] = useState('Inbound Call');
const [agentNotes, setAgentNotes] = useState('');
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch doctors on mount
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 }
} } } }`,
).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(() => {});
}, [isOpen]);
// Fetch booked slots when doctor + date selected
useEffect(() => {
if (!doctor || !date) {
setBookedSlots([]);
return;
}
setLoadingSlots(true);
apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
).then(data => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter(e => {
const status = e.node.appointmentStatus;
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
});
const slots = activeAppointments.map(e => {
const dt = new Date(e.node.scheduledAt);
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
});
// In edit mode, don't block the current appointment's slot
if (isEditMode && existingAppointment) {
const currentDt = new Date(existingAppointment.scheduledAt);
const currentSlot = `${currentDt.getHours().toString().padStart(2, '0')}:${currentDt.getMinutes().toString().padStart(2, '0')}`;
setBookedSlots(slots.filter(s => s !== currentSlot));
} else {
setBookedSlots(slots);
}
}).catch(() => setBookedSlots([]))
.finally(() => setLoadingSlots(false));
}, [doctor, date, isEditMode, existingAppointment]);
// Reset doctor when department changes
useEffect(() => {
setDoctor(null);
setTimeSlot(null);
}, [department]);
// Reset time slot when doctor or date changes
useEffect(() => {
setTimeSlot(null);
}, [doctor, date]);
// Derive department and doctor lists from fetched data
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
const filteredDoctors = department
? doctors.filter(d => d.department === department)
: doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
const timeSlotSelectItems = timeSlotItems.map(slot => ({
...slot,
isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
}));
const handleSave = async () => {
if (!date || !timeSlot || !doctor || !department) {
setError('Please fill in the required fields: date, time, doctor, and department.');
@@ -100,15 +201,45 @@ export const AppointmentForm = ({
setError(null);
try {
const { apiClient } = await import('@/lib/api-client');
// Combine date + time slot into ISO datetime
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
const selectedDoctor = doctors.find(d => d.id === doctor);
const doctorLabel = doctorItems.find((d) => d.id === doctor)?.label ?? doctor;
const departmentLabel = departmentItems.find((d) => d.id === department)?.label ?? department;
if (isEditMode && existingAppointment) {
// Update existing appointment
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,
},
},
);
notify.success('Appointment Updated');
} else {
// Double-check slot availability before booking
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
}) { edges { node { appointmentStatus } } } }`,
);
const activeBookings = checkResult.appointments.edges.filter(e =>
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
);
if (activeBookings.length > 0) {
setError('This slot was just booked by someone else. Please select a different time.');
setIsSaving(false);
return;
}
// Create appointment on platform
// Create appointment
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
@@ -116,11 +247,12 @@ export const AppointmentForm = ({
{
data: {
scheduledAt,
durationMinutes: 30,
durationMin: 30,
appointmentType: 'CONSULTATION',
appointmentStatus: 'SCHEDULED',
doctorName: doctorLabel,
department: departmentLabel,
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(leadId ? { patientId: leadId } : {}),
},
@@ -129,8 +261,7 @@ export const AppointmentForm = ({
// Update lead status if we have a matched lead
if (leadId) {
await apiClient
.graphql(
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
@@ -141,38 +272,73 @@ export const AppointmentForm = ({
lastContactedAt: new Date().toISOString(),
},
},
)
.catch((err: unknown) => console.warn('Failed to update lead:', err));
).catch((err: unknown) => console.warn('Failed to update lead:', err));
}
}
onSaved?.();
} catch (err) {
console.error('Failed to create appointment:', err);
setError(err instanceof Error ? err.message : 'Failed to create appointment. Please try again.');
console.error('Failed to save appointment:', err);
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
} finally {
setIsSaving(false);
}
};
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);
}
};
if (!isOpen) return null;
return (
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} className="max-w-120">
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="rounded-xl border border-secondary bg-primary p-4">
{/* Header with close button */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<CalendarPlus02 className="size-5 text-fg-brand-primary" />
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-primary">Book Appointment</h2>
<p className="text-sm text-tertiary">Schedule a new patient visit</p>
<h3 className="text-sm font-semibold text-primary">
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
</h3>
<p className="text-xs text-tertiary">
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
</p>
</div>
</div>
</SlideoutMenu.Header>
<button
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<XClose className="size-4" />
</button>
</div>
<SlideoutMenu.Content>
<div className="flex flex-col gap-4">
{/* Patient Info */}
{/* Form fields */}
<div className="flex flex-col gap-3">
{/* Patient Info — only for new appointments */}
{!isEditMode && (
<>
<div className="flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
Patient Information
@@ -212,8 +378,9 @@ export const AppointmentForm = ({
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{/* Divider */}
<div className="border-t border-secondary" />
</>
)}
{/* Appointment Details */}
<div className="flex flex-col gap-1">
@@ -222,6 +389,7 @@ export const AppointmentForm = ({
</span>
</div>
{!isEditMode && (
<Select
label="Clinic / Branch"
placeholder="Select clinic"
@@ -231,30 +399,32 @@ export const AppointmentForm = ({
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
)}
<Select
label="Department / Specialty"
placeholder="Select department"
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isRequired
isDisabled={doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select
label="Doctor"
placeholder="Select doctor"
items={doctorItems}
placeholder={!department ? 'Select department first' : 'Select doctor'}
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isRequired
isDisabled={!department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<div className="grid grid-cols-2 gap-3">
<Input
label="Date"
type="date"
@@ -262,17 +432,43 @@ export const AppointmentForm = ({
onChange={setDate}
isRequired
/>
<Select
label="Time Slot"
placeholder="Select time"
items={timeSlotItems}
selectedKey={timeSlot}
onSelectionChange={(key) => setTimeSlot(key as string)}
isRequired
{/* Time slot grid */}
{doctor && date && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-secondary">
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
</span>
<div className="grid grid-cols-4 gap-1.5">
{timeSlotSelectItems.map(slot => {
const isBooked = slot.isDisabled;
const isSelected = timeSlot === slot.id;
return (
<button
key={slot.id}
type="button"
disabled={isBooked}
onClick={() => setTimeSlot(slot.id)}
className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
isBooked
? 'cursor-not-allowed bg-disabled text-disabled line-through'
: isSelected
? 'bg-brand-solid text-white ring-2 ring-brand'
: 'cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover',
)}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{timeSlotItems.find(t => t.id === slot.id)?.label ?? slot.id}
</button>
);
})}
</div>
</div>
)}
{!doctor || !date ? (
<p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p>
) : null}
<TextArea
label="Chief Complaint"
@@ -282,10 +478,11 @@ export const AppointmentForm = ({
rows={2}
/>
{/* Divider */}
{/* Additional Info — only for new appointments */}
{!isEditMode && (
<>
<div className="border-t border-secondary" />
{/* Additional Info */}
<Checkbox
isSelected={isReturning}
onChange={setIsReturning}
@@ -307,6 +504,8 @@ export const AppointmentForm = ({
onChange={setAgentNotes}
rows={2}
/>
</>
)}
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">
@@ -314,26 +513,25 @@ export const AppointmentForm = ({
</div>
)}
</div>
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-end gap-3">
<Button size="md" color="secondary" onClick={close}>
Cancel
{/* Footer buttons */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
<div>
{isEditMode && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment
</Button>
<Button
size="md"
color="primary"
isLoading={isSaving}
showTextWhileLoading
onClick={handleSave}
>
{isSaving ? 'Booking...' : 'Book Appointment'}
)}
</div>
<div className="flex items-center gap-3">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { cx } from '@/utils/cx';
interface DispositionFormProps {
onSubmit: (disposition: CallDisposition, notes: string) => void;
defaultDisposition?: CallDisposition | null;
}
const dispositionOptions: Array<{
@@ -51,8 +52,8 @@ const dispositionOptions: Array<{
},
];
export const DispositionForm = ({ onSubmit }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null);
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
const [notes, setNotes] = useState('');
const handleSubmit = () => {

View File

@@ -21,6 +21,8 @@ import { NavAccountCard } from "@/components/application/app-navigation/base-com
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
import { cx } from "@/utils/cx";
@@ -127,6 +129,15 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
navigate('/login');
};
const handleForceReady = async () => {
try {
await apiClient.post('/api/ozonetel/agent-ready', {});
notify.success('Agent Ready', 'Agent state has been reset to Ready');
} catch {
notify.error('Force Ready Failed', 'Could not reset agent state');
}
};
const navSections = getNavSections(user.role);
const content = (
@@ -219,6 +230,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
}]}
selectedAccountId="current"
onSignOut={handleSignOut}
onForceReady={handleForceReady}
/>
)}
</div>