- Missed call queue with FIFO auto-assignment, dedup, SLA tracking - Status sub-tabs (Pending/Attempted/Completed/Invalid) in worklist - missedCallId passed through disposition flow for callback tracking - Login page redesigned: centered white card on blue background - Disposition button changed to content-width - NavAccountCard popover close fix on menu item click Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Phase 2: Missed Call Queue + Login Redesign + Button Fix
Date: 2026-03-22
PRD Reference: US 7 (Missed Call Queue), Login Page Redesign, Button Width Fix
Branch: dev
1. Missed Call Queue (US 7)
1.1 Data Model
The existing Call entity on the Fortytwo platform is extended with 4 custom fields (already added via admin portal):
| GraphQL Field Name | DB Column | Type | Purpose |
|---|---|---|---|
callbackstatus |
callbackstatus |
SELECT | Lifecycle: PENDING_CALLBACK, CALLBACK_ATTEMPTED, CALLBACK_COMPLETED, INVALID, WRONG_NUMBER |
callsourcenumber |
callsourcenumber |
TEXT | Which DID/branch the patient called |
missedcallcount |
missedcallcount |
NUMBER | Dedup counter — same number calling multiple times before callback |
callbackattemptedat |
callbackattemptedat |
DATE_TIME | Timestamp of first callback attempt |
Important: Custom fields use all-lowercase GraphQL names (not camelCase). Verified via introspection and mutation test on staging.
Existing fields used:
callStatus: MISSED— identifies missed callsagentName— tracks which agent is assigneddisposition— records callback outcomecallerNumber— caller's phone (PHONES type, accessed ascallerNumber { primaryPhoneNumber })startedAt— when the call was missedleadId— linked lead (if matched)
1.2 Sidecar: Missed Queue Service
Extend the existing src/worklist/ module (already handles missed call data and is registered in app.module.ts).
New files:
src/worklist/missed-queue.service.ts— Queue logic (ingestion, dedup, assignment)
Modified files:
src/worklist/worklist.controller.ts— Add missed queue endpointssrc/worklist/worklist.module.ts— Register MissedQueueService
Auth model:
GET /api/missed-queueandPATCH /api/missed-queue/:id/status— use agent's forwarded auth token (same as existing worklist endpoints)- Ingestion timer and auto-assignment — use server API key (
PLATFORM_API_KEY) since these run without a user request
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/missed-queue |
Returns missed calls for current agent, grouped by callbackstatus |
POST |
/api/missed-queue/ingest |
Polls Ozonetel abandonCalls, deduplicates, writes to platform |
PATCH |
/api/missed-queue/:id/status |
Updates callbackstatus on a Call record |
POST |
/api/missed-queue/assign |
Assigns oldest unassigned PENDING_CALLBACK call to an agent |
Ingestion Flow (runs every 30s via setInterval on service init)
- Call
OzonetelAgentService.getAbandonCalls()withfromTime/toTimelimited to the last 5 minutes (the method already supports these parameters). This prevents re-processing the entire day's abandon calls on service restart. - Normalize caller phone numbers to
+91XXXXXXXXXXformat before any query or write (Ozonetel may return numbers in varying formats like009919876543210or9876543210). - For each abandoned call:
- Extract
callerID(phone number, normalized) anddid(source number) - Query platform:
calls(filter: { callerNumber: { primaryPhoneNumber: { eq: "<normalized_number>" } }, callbackstatus: { eq: PENDING_CALLBACK } }) - Match found →
updateCall: incrementmissedcallcount, updatestartedAtto latest timestamp - No match →
createCall:mutation { createCall(data: { callStatus: MISSED, direction: INBOUND, callerNumber: { primaryPhoneNumber: "<normalized_number>", primaryPhoneCallingCode: "+91" }, callsourcenumber: "<DID>", callbackstatus: PENDING_CALLBACK, missedcallcount: 1, startedAt: "<timestamp>" }) { id } }
- Extract
- Track ingested Ozonetel
monitorUCIDvalues in a Set to avoid re-processing within the same poll cycle
Auto-Assignment (triggered on two events)
Assignment fires when an agent becomes available via either path:
- Disposition submission (
POST /api/ozonetel/dispose): After an agent completes a call and submits disposition, they become Ready. This is the primary trigger — most "agent available" transitions happen here. - Manual state change (
POST /api/ozonetel/agent-state): When an agent manually toggles to Ready via AgentStatusToggle.
In both cases, call MissedQueueService.assignNext(agentName):
- Query platform: oldest Call with
callbackstatus: PENDING_CALLBACKandagentNameis null/empty, ordered bystartedAt: AscNullsLast - If found →
updateCallsettingagentNameto the available agent - Use optimistic concurrency: if the update fails (another agent claimed it first), retry with the next oldest call
- Return assigned call to frontend (so it can surface at top of worklist)
Note on race conditions: Since this is a single-instance sidecar, a simple in-memory mutex around the assignment query+update is sufficient to prevent two simultaneous Ready events from claiming the same call.
Status Transitions
| Trigger | From Status | To Status | Additional Updates |
|---|---|---|---|
| Agent clicks call-back | PENDING_CALLBACK |
CALLBACK_ATTEMPTED |
Set callbackattemptedat |
| Disposition: APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED | CALLBACK_ATTEMPTED |
CALLBACK_COMPLETED |
— |
| Disposition: NO_ANSWER (after max retries) | CALLBACK_ATTEMPTED |
CALLBACK_ATTEMPTED |
Stays attempted, agent can retry |
| Disposition: WRONG_NUMBER | CALLBACK_ATTEMPTED |
WRONG_NUMBER |
— |
| Agent marks invalid | Any | INVALID |
— |
1.3 Sidecar: Worklist Update
Update WorklistService.getMissedCalls() to include the new fields in the query:
calls(first: 20, filter: {
agentName: { eq: "<agent>" },
callStatus: { eq: MISSED },
callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] }
}, orderBy: [{ startedAt: AscNullsLast }]) {
edges { node {
id name createdAt
direction callStatus agentName
callerNumber { primaryPhoneNumber }
startedAt endedAt durationSec
disposition leadId
callbackstatus callsourcenumber missedcallcount callbackattemptedat
} }
}
1.4 Frontend: Worklist Panel Changes
src/hooks/use-worklist.ts:
- Add
callbackstatus,callsourcenumber,missedcallcount,callbackattemptedattoMissedCalltype - Transform data from sidecar response (fields are already lowercase, minimal mapping needed)
src/components/call-desk/worklist-panel.tsx:
Replace the flat "Missed" tab with status sub-tabs:
[All] [Missed] [Callbacks] [Follow-ups] [Leads]
│
└── [Pending | Attempted | Completed | Invalid]
Pending sub-tab (default view):
- FIFO ordered (oldest first, matching
AscNullsLastsort) - Row content: caller phone, time since missed, missed call count badge (shown if >1), call source number, SLA color indicator
- SLA thresholds: green (<15 min), orange (15–30 min), red (>30 min) — existing logic
- Click-to-call → triggers callback, sidecar auto-transitions to
CALLBACK_ATTEMPTED
Attempted sub-tab:
- Calls where agent tried calling back but no final resolution yet
- Row content: caller phone, time since first attempt (
callbackattemptedat), last disposition - Click-to-call for retry
Completed / Invalid sub-tabs:
- Read-only history of resolved missed calls
- Shows: caller phone, final disposition, resolution timestamp
Assignment notification: When auto-assigned, the missed call appears at top of the worklist with a highlighted "Missed Call" badge. A toast notification alerts the agent.
1.5 Frontend: Post-Callback Status Update
When an agent clicks call-back on a missed call:
- Frontend calls
PATCH /api/missed-queue/:id/statuswith{ status: 'CALLBACK_ATTEMPTED' } - Normal outbound call flow begins via SIP
- After call ends → disposition form → disposition submitted → sidecar maps disposition to final
callbackstatusand updates platform
This integrates with the existing ActiveCallCard disposition flow. The frontend must pass the missed Call record ID as missedCallId in the disposition request body so the sidecar can look up and update the callbackstatus. The dispose endpoint currently receives { ucid, disposition, callerPhone, direction, durationSec, leadId, notes } — add missedCallId?: string as an optional field. When present, the sidecar updates the corresponding Call record's callbackstatus based on disposition mapping:
- APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED →
CALLBACK_COMPLETED - WRONG_NUMBER →
WRONG_NUMBER - NO_ANSWER → stays
CALLBACK_ATTEMPTED(agent can retry)
2. Login Page Redesign
Current State
Split-panel layout: 60% blue left panel with marketing feature cards (Unified Lead Inbox, Campaign Intelligence, Speed to Contact) + 40% white right panel with login form.
Target State
- Full blue background using
bg-brand-section(existing brand blue token) - Centered white card (~420px max-width,
rounded-xl,shadow-xl) - Inside the card:
- Helix Engage logo (prominent, centered)
- "Global Hospital" subtitle
- Google sign-in button with "OR CONTINUE WITH" divider
- Email input
- Password input with eye toggle
- Remember me checkbox + Forgot password link (same row)
- Sign in button (full-width within card — standard for login forms)
- Footer: subtle "Powered by FortyTwo" text below the card
- No left panel, no marketing copy, no feature cards
- Mobile: card fills screen width with padding
File Changes
src/pages/login.tsx— restructure layout, remove left panel, center card
3. Button Width Fix
Problem
Buttons in call desk inline forms (disposition, appointment, enquiry, transfer) use w-full, spanning the entire container width. This looks awkward in wide panels.
Fix
Change buttons in these forms from w-full to w-auto with right-aligned layout (flex justify-end gap-3).
Scope
Login page buttons stay w-full (narrow container, standard practice).
Affected Files
src/components/call-desk/disposition-form.tsx— Save Disposition button (confirmedw-full)- Other call desk form buttons (appointment, enquiry, transfer) — verify at implementation time, may already be content-width
Technical Notes
GraphQL Field Naming
Custom fields added via admin portal use all-lowercase GraphQL names:
callbackstatus(notcallbackStatus)callsourcenumber(notcallSourceNumber)missedcallcount(notmissedCallCount)callbackattemptedat(notcallbackAttemptedAt)
Managed (app-defined) fields retain camelCase (callStatus, agentName, etc.).
Verified on Staging
- Queries:
calls(first: 2) { edges { node { callbackstatus callsourcenumber missedcallcount callbackattemptedat } } }✅ - Mutations:
updateCall(id: "...", data: { callbackstatus: PENDING_CALLBACK, missedcallcount: 1 })✅ - Staging DB:
fortytwo_staging, workspace schema:workspace_3x7sonctrktrxft4b0bwuc26x, table:_call
Dedup Strategy
Deduplication is by caller phone number against PENDING_CALLBACK records. Once a missed call transitions to any other status, a new missed call from the same number creates a fresh record. This prevents stale dedup.
Ozonetel Ingestion Idempotency
Each poll queries only the last 5 minutes via fromTime/toTime parameters, preventing full-day reprocessing on restart. Within a poll cycle, processed monitorUCID values are tracked in a Set<string> to avoid duplicates. The platform dedup query (phone number + PENDING_CALLBACK) provides a second safety net.
Phone Number Normalization
All phone numbers are normalized to +91XXXXXXXXXX format before writes and queries. Ozonetel may return numbers as 009919876543210, 919876543210, or 9876543210 — strip leading 0091/91/0 prefixes, then prepend +91.
Edge Cases
- Multiple DIDs: If a caller dials branch A, then branch B before callback, the records merge (count incremented). The
callsourcenumberupdates to the latest branch. This is intentional — the callback is to the patient, not the branch. - Agent goes offline after assignment: Assigned missed calls stay with the agent. No automatic requeue. Supervisors can manually reassign in Phase 3.
- Ingestion poll interval: 30s, configurable via
MISSED_QUEUE_POLL_INTERVAL_MSenv var.