diff --git a/docs/superpowers/plans/2026-03-22-phase2-missed-call-queue-login-redesign.md b/docs/superpowers/plans/2026-03-22-phase2-missed-call-queue-login-redesign.md new file mode 100644 index 0000000..0ccb00f --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-phase2-missed-call-queue-login-redesign.md @@ -0,0 +1,1041 @@ +# Phase 2: Missed Call Queue + Login Redesign + Button Fix — 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:** Implement missed call queue with FIFO auto-assignment, dedup, and callback SLA tracking; redesign the login page; fix button widths in call desk forms. + +**Architecture:** Sidecar polls Ozonetel abandon calls every 30s, deduplicates by phone number, writes/updates Call records on the platform via GraphQL. Auto-assignment triggered on agent Ready state (via dispose or manual toggle). Frontend shows status-tabbed missed call view in the worklist panel. + +**Tech Stack:** NestJS sidecar (TypeScript), Fortytwo platform GraphQL API, React + Tailwind frontend + +**Spec:** `docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md` + +--- + +## File Map + +### Sidecar (`helix-engage-server/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `worklist/missed-queue.service.ts` | Create | Ingestion polling, dedup, auto-assignment, status updates | +| `worklist/worklist.controller.ts` | Modify | Add missed queue endpoints (GET, PATCH, POST assign) | +| `worklist/worklist.service.ts` | Modify | Add new fields to missed call query, change sort order to FIFO | +| `worklist/worklist.module.ts` | Modify | Register MissedQueueService, forwardRef OzonetelAgentModule | +| `ozonetel/ozonetel-agent.controller.ts` | Modify | Hook auto-assignment into dispose + agent-state endpoints, add `missedCallId` to dispose | +| `ozonetel/ozonetel-agent.module.ts` | Modify | forwardRef WorklistModule for MissedQueueService access | +| `platform/platform-graphql.service.ts` | Modify | Make `query()` method public (currently private) | +| `config/configuration.ts` | Modify | Add `MISSED_QUEUE_POLL_INTERVAL_MS` env var | + +### Frontend (`helix-engage/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `hooks/use-worklist.ts` | Modify | Add callback fields to MissedCall type, transform data | +| `components/call-desk/worklist-panel.tsx` | Modify | Add status sub-tabs under Missed tab | +| `components/call-desk/active-call-card.tsx` | Modify | Pass `missedCallId` to dispose endpoint when callback | +| `components/call-desk/disposition-form.tsx` | Modify | Fix button width | +| `pages/login.tsx` | Rewrite | Centered white card on blue background | +| `pages/call-desk.tsx` | Modify | Track active missed call ID for disposition flow | + +--- + +## Task 1: MissedQueueService — Phone Normalization + Core Service Skeleton + +**Files:** +- Create: `helix-engage-server/src/worklist/missed-queue.service.ts` + +- [ ] **Step 1: Create the service with phone normalization utility** + +```typescript +// helix-engage-server/src/worklist/missed-queue.service.ts +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; + +// Normalize phone to +91XXXXXXXXXX format +export function normalizePhone(raw: string): string { + let digits = raw.replace(/[^0-9]/g, ''); + // Strip leading country code variations: 0091, 91, 0 + if (digits.startsWith('0091')) digits = digits.slice(4); + else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2); + else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1); + return `+91${digits.slice(-10)}`; +} + +@Injectable() +export class MissedQueueService implements OnModuleInit { + private readonly logger = new Logger(MissedQueueService.name); + private readonly pollIntervalMs: number; + private readonly processedUcids = new Set(); + private assignmentMutex = false; + + constructor( + private readonly config: ConfigService, + private readonly platform: PlatformGraphqlService, + private readonly ozonetel: OzonetelAgentService, + ) { + this.pollIntervalMs = this.config.get('missedQueue.pollIntervalMs', 30000); + } + + onModuleInit() { + this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`); + setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs); + } + + async ingest(): Promise<{ created: number; updated: number }> { + // Implemented in Task 2 + return { created: 0, updated: 0 }; + } + + async assignNext(agentName: string): Promise { + // Implemented in Task 3 + return null; + } + + async updateStatus(callId: string, status: string, authHeader: string): Promise { + // Implemented in Task 4 + return {}; + } + + async getMissedQueue(agentName: string, authHeader: string): Promise { + // Implemented in Task 4 + return { pending: [], attempted: [], completed: [], invalid: [] }; + } +} +``` + +- [ ] **Step 2: Make `PlatformGraphqlService.query()` public** + +The `query()` method at `platform/platform-graphql.service.ts:17` is `private`. The `MissedQueueService` needs to call it for server-to-server operations (ingestion, assignment). Change `private` to `public`: + +```typescript +// Line 17: change from +private async query(query: string, variables?: Record): Promise { +// to +async query(query: string, variables?: Record): Promise { +``` + +- [ ] **Step 3: Register in WorklistModule with forwardRef** + +Modify `helix-engage-server/src/worklist/worklist.module.ts`: + +```typescript +// Add imports at top +import { forwardRef } from '@nestjs/common'; +import { MissedQueueService } from './missed-queue.service'; +import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; + +// Update @Module decorator: +@Module({ + imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)], + controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], + providers: [WorklistService, MissedQueueService], + exports: [MissedQueueService], +}) +``` + +Check that `OzonetelAgentModule` exports `OzonetelAgentService`. Read `helix-engage-server/src/ozonetel/ozonetel-agent.module.ts` to verify — if not exported, add it. + +- [ ] **Step 4: Add config entry** + +Modify `helix-engage-server/src/config/configuration.ts` — add inside the returned config object: + +```typescript +missedQueue: { + pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10), +}, +``` + +- [ ] **Step 5: Verify sidecar compiles** + +Run: `cd helix-engage-server && npm run build` +Expected: Clean compilation, no errors + +- [ ] **Step 6: Commit** + +```bash +git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/worklist/worklist.module.ts helix-engage-server/src/platform/platform-graphql.service.ts helix-engage-server/src/config/configuration.ts +git commit -m "feat: add MissedQueueService skeleton with phone normalization" +``` + +--- + +## Task 2: MissedQueueService — Ingestion + Dedup + +**Files:** +- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts` + +- [ ] **Step 1: Implement the `ingest()` method** + +Replace the placeholder `ingest()` method in `missed-queue.service.ts`: + +```typescript +async ingest(): Promise<{ created: number; updated: number }> { + let created = 0; + let updated = 0; + + // Query last 5 minutes of abandon calls + const now = new Date(); + const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000); + const format = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19); + + let abandonCalls: any[]; + try { + abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: format(fiveMinAgo), toTime: format(now) }); + } catch (err) { + this.logger.warn(`Failed to fetch abandon calls: ${err}`); + return { created: 0, updated: 0 }; + } + + if (!abandonCalls?.length) return { created: 0, updated: 0 }; + + for (const call of abandonCalls) { + const ucid = call.monitorUCID; + if (!ucid || this.processedUcids.has(ucid)) continue; + this.processedUcids.add(ucid); + + const phone = normalizePhone(call.callerID || ''); + if (!phone || phone.length < 13) continue; // +91 + 10 digits = 13 + + const did = call.did || ''; + const callTime = call.callTime || new Date().toISOString(); + + try { + // Check for existing PENDING_CALLBACK record with same phone + const existing = await this.platform.query( + `{ calls(first: 1, filter: { + callbackstatus: { eq: PENDING_CALLBACK }, + callerNumber: { primaryPhoneNumber: { eq: "${phone}" } } + }) { edges { node { id missedcallcount } } } }`, + ); + + const existingNode = existing?.calls?.edges?.[0]?.node; + + if (existingNode) { + // Dedup: increment count, update timestamp + const newCount = (existingNode.missedcallcount || 1) + 1; + await this.platform.query( + `mutation { updateCall(id: "${existingNode.id}", data: { + missedcallcount: ${newCount}, + startedAt: "${callTime}", + callsourcenumber: "${did}" + }) { id } }`, + ); + updated++; + this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`); + } else { + // New missed call record + await this.platform.query( + `mutation { createCall(data: { + callStatus: MISSED, + direction: INBOUND, + callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }, + callsourcenumber: "${did}", + callbackstatus: PENDING_CALLBACK, + missedcallcount: 1, + startedAt: "${callTime}" + }) { id } }`, + ); + created++; + this.logger.log(`Created missed call record for ${phone}`); + } + } catch (err) { + this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`); + } + } + + // Trim processedUcids set to prevent unbounded growth (keep last 500) + if (this.processedUcids.size > 500) { + const arr = Array.from(this.processedUcids); + this.processedUcids.clear(); + arr.slice(-200).forEach(u => this.processedUcids.add(u)); + } + + if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`); + return { created, updated }; +} +``` + +**Note**: `this.platform.query()` uses the server API key (not user JWT). The `getAbandonCalls()` method accepts an options object: `{ fromTime?, toTime?, campaignName? }` (see `ozonetel-agent.service.ts:263`). + +- [ ] **Step 2: Verify sidecar compiles** + +Run: `cd helix-engage-server && npm run build` +Expected: Clean compilation + +- [ ] **Step 3: Commit** + +```bash +git add helix-engage-server/src/worklist/missed-queue.service.ts +git commit -m "feat: implement missed call ingestion with dedup and phone normalization" +``` + +--- + +## Task 3: MissedQueueService — Auto-Assignment + +**Files:** +- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts` +- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` + +- [ ] **Step 1: Implement `assignNext()` in the service** + +Replace the placeholder `assignNext()` method: + +```typescript +async assignNext(agentName: string): Promise { + // Simple mutex to prevent race conditions + if (this.assignmentMutex) return null; + this.assignmentMutex = true; + + try { + // Find oldest unassigned PENDING_CALLBACK call + const result = await this.platform.query( + `{ calls(first: 1, filter: { + callbackstatus: { eq: PENDING_CALLBACK }, + agentName: { eq: "" } + }, orderBy: [{ startedAt: AscNullsLast }]) { + edges { node { + id callerNumber { primaryPhoneNumber } + startedAt callsourcenumber missedcallcount + } } + } }`, + ); + + const call = result?.calls?.edges?.[0]?.node; + if (!call) { + // Also check for null agentName (not just empty string) + const result2 = await this.platform.query( + `{ calls(first: 1, filter: { + callbackstatus: { eq: PENDING_CALLBACK }, + agentName: { is: NULL } + }, orderBy: [{ startedAt: AscNullsLast }]) { + edges { node { + id callerNumber { primaryPhoneNumber } + startedAt callsourcenumber missedcallcount + } } + } }`, + ); + const call2 = result2?.calls?.edges?.[0]?.node; + if (!call2) return null; + + await this.platform.query( + `mutation { updateCall(id: "${call2.id}", data: { agentName: "${agentName}" }) { id } }`, + ); + this.logger.log(`Assigned missed call ${call2.id} to ${agentName}`); + return call2; + } + + await this.platform.query( + `mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`, + ); + this.logger.log(`Assigned missed call ${call.id} to ${agentName}`); + return call; + } catch (err) { + this.logger.warn(`Assignment failed: ${err}`); + return null; + } finally { + this.assignmentMutex = false; + } +} +``` + +- [ ] **Step 2: Wire OzonetelAgentModule ↔ WorklistModule with forwardRef** + +**This must be done BEFORE adding injections to the controller**, otherwise NestJS cannot resolve dependencies. + +In `ozonetel-agent.module.ts`: +```typescript +import { forwardRef } from '@nestjs/common'; +import { WorklistModule } from '../worklist/worklist.module'; + +@Module({ + imports: [forwardRef(() => WorklistModule)], + // ... keep existing controllers, providers, exports +}) +``` + +Verify `worklist.module.ts` already has `forwardRef(() => OzonetelAgentModule)` from Task 1 Step 3. + +- [ ] **Step 3: Hook auto-assignment into the dispose endpoint** + +Modify `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`. + +Add imports at top: + +```typescript +import { MissedQueueService } from '../worklist/missed-queue.service'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +``` + +Add to constructor (after `private readonly config: ConfigService`): + +```typescript +private readonly missedQueue: MissedQueueService, +private readonly platform: PlatformGraphqlService, +``` + +Then at the END of the dispose handler (after line ~133, after the try-catch for `setDisposition`), add: + +```typescript +// Handle missed call callback status update +const missedCallId = body.missedCallId; +if (missedCallId) { + const statusMap: Record = { + APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED', + INFO_PROVIDED: 'CALLBACK_COMPLETED', + FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED', + CALLBACK_REQUESTED: 'CALLBACK_COMPLETED', + WRONG_NUMBER: 'WRONG_NUMBER', + // NO_ANSWER stays CALLBACK_ATTEMPTED — agent can retry + }; + const newStatus = statusMap[body.disposition]; + if (newStatus) { + try { + await this.platform.query( + `mutation { updateCall(id: "${missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`, + ); + } catch (err) { + this.logger.warn(`Failed to update missed call status: ${err}`); + } + } +} + +// Auto-assign next missed call to this agent +try { + await this.missedQueue.assignNext(this.defaultAgentId); +} catch (err) { + this.logger.warn(`Auto-assignment after dispose failed: ${err}`); +} +``` + +- [ ] **Step 4: Hook auto-assignment into agent-state endpoint** + +In the same controller, at the end of the agent-state handler (after line ~76), add: + +```typescript +// Auto-assign missed call when agent goes Ready +if (body.state === 'Ready') { + try { + const assigned = await this.missedQueue.assignNext(this.agentId); + if (assigned) { + return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned }; + } + } catch (err) { + this.logger.warn(`Auto-assignment on Ready failed: ${err}`); + } +} +``` + +- [ ] **Step 5: Verify sidecar compiles** + +Run: `cd helix-engage-server && npm run build` +Expected: Clean compilation + +- [ ] **Step 6: Commit** + +```bash +git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts helix-engage-server/src/ozonetel/ozonetel-agent.module.ts helix-engage-server/src/worklist/worklist.module.ts +git commit -m "feat: auto-assign missed calls on agent Ready state" +``` + +--- + +## Task 4: Missed Queue Endpoints + Worklist Update + +**Files:** +- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts` +- Modify: `helix-engage-server/src/worklist/worklist.controller.ts` +- Modify: `helix-engage-server/src/worklist/worklist.service.ts` + +- [ ] **Step 1: Implement `getMissedQueue()` and `updateStatus()` in the service** + +Replace the placeholder methods in `missed-queue.service.ts`: + +```typescript +async getMissedQueue(agentName: string, authHeader: string): Promise<{ + pending: any[]; + attempted: any[]; + completed: any[]; + invalid: any[]; +}> { + const fields = `id name createdAt direction callStatus agentName + callerNumber { primaryPhoneNumber } + startedAt endedAt durationSec disposition leadId + callbackstatus callsourcenumber missedcallcount callbackattemptedat`; + + const query = (status: string) => `{ calls(first: 50, filter: { + agentName: { eq: "${agentName}" }, + callStatus: { eq: MISSED }, + callbackstatus: { eq: ${status} } + }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`; + + try { + const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([ + this.platform.queryWithAuth(query('PENDING_CALLBACK'), undefined, authHeader), + this.platform.queryWithAuth(query('CALLBACK_ATTEMPTED'), undefined, authHeader), + this.platform.queryWithAuth(query('CALLBACK_COMPLETED'), undefined, authHeader), + this.platform.queryWithAuth(query('INVALID'), undefined, authHeader), + this.platform.queryWithAuth(query('WRONG_NUMBER'), undefined, authHeader), + ]); + + const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? []; + + return { + pending: extract(pending), + attempted: extract(attempted), + completed: [...extract(completed), ...extract(wrongNumber)], + invalid: extract(invalid), + }; + } catch (err) { + this.logger.warn(`Failed to fetch missed queue: ${err}`); + return { pending: [], attempted: [], completed: [], invalid: [] }; + } +} + +async updateStatus(callId: string, status: string, authHeader: string): Promise { + const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER']; + if (!validStatuses.includes(status)) { + throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`); + } + + const data: Record = { callbackstatus: status }; + if (status === 'CALLBACK_ATTEMPTED') { + data.callbackattemptedat = new Date().toISOString(); + } + + const dataStr = Object.entries(data) + .map(([k, v]) => typeof v === 'string' && !validStatuses.includes(v) ? `${k}: "${v}"` : `${k}: ${v}`) + .join(', '); + + return this.platform.queryWithAuth( + `mutation { updateCall(id: "${callId}", data: { ${dataStr} }) { id callbackstatus callbackattemptedat } }`, + undefined, + authHeader, + ); +} +``` + +- [ ] **Step 2: Add endpoints to the controller** + +Modify `helix-engage-server/src/worklist/worklist.controller.ts`. Add imports and inject `MissedQueueService`: + +```typescript +import { Controller, Get, Patch, Post, Headers, Param, Body, HttpException, Logger } from '@nestjs/common'; +import { MissedQueueService } from './missed-queue.service'; +``` + +Add `MissedQueueService` to constructor: + +```typescript +constructor( + private readonly worklist: WorklistService, + private readonly missedQueue: MissedQueueService, + private readonly platform: PlatformGraphqlService, +) {} +``` + +Add these endpoints after the existing `@Get()`: + +```typescript +@Get('missed-queue') +async getMissedQueue(@Headers('authorization') authHeader: string) { + if (!authHeader) throw new HttpException('Authorization header required', 401); + const agentName = await this.resolveAgentName(authHeader); + return this.missedQueue.getMissedQueue(agentName, authHeader); +} + +@Patch('missed-queue/:id/status') +async updateMissedCallStatus( + @Param('id') id: string, + @Headers('authorization') authHeader: string, + @Body() body: { status: string }, +) { + if (!authHeader) throw new HttpException('Authorization header required', 401); + if (!body.status) throw new HttpException('status is required', 400); + return this.missedQueue.updateStatus(id, body.status, authHeader); +} +``` + +- [ ] **Step 3: Update worklist service to include new fields** + +Modify `helix-engage-server/src/worklist/worklist.service.ts` — update the `getMissedCalls` method (lines 77-95). + +Change the query to include new fields and FIFO ordering: + +```typescript +private async getMissedCalls(agentName: string, authHeader: string): Promise { + try { + const data = await this.platform.queryWithAuth( + `{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, 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 + } } } }`, + undefined, + authHeader, + ); + return data.calls.edges.map((e: any) => e.node); + } catch (err) { + this.logger.warn(`Failed to fetch missed calls: ${err}`); + return []; + } +} +``` + +**Note**: Sort changed from `DescNullsLast` to `AscNullsLast` for FIFO ordering. Added `callbackstatus` filter to only show active missed calls in the worklist (not completed/invalid). + +- [ ] **Step 4: Verify sidecar compiles** + +Run: `cd helix-engage-server && npm run build` +Expected: Clean compilation + +- [ ] **Step 5: Commit** + +```bash +git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/worklist/worklist.controller.ts helix-engage-server/src/worklist/worklist.service.ts +git commit -m "feat: missed queue endpoints and worklist FIFO ordering with callback fields" +``` + +--- + +## Task 5: Frontend — Worklist Types + Hook Update + +**Files:** +- Modify: `helix-engage/src/hooks/use-worklist.ts` + +- [ ] **Step 1: Update MissedCall type and transform** + +In `helix-engage/src/hooks/use-worklist.ts`, update the `MissedCall` type (lines 5-18) to add the new fields: + +```typescript +type MissedCall = { + id: string; + createdAt: string; + callDirection: string | null; + callStatus: string | null; + callerNumber: { number: string; callingCode: string }[] | null; + agentName: string | null; + startedAt: string | null; + endedAt: string | null; + durationSeconds: number | null; + disposition: string | null; + callNotes: string | null; + leadId: string | null; + // New callback tracking fields + callbackstatus: string | null; + callsourcenumber: string | null; + missedcallcount: number | null; + callbackattemptedat: string | null; +}; +``` + +The transform in `missedCalls` mapping (lines 103-110) already spreads all fields — the new lowercase fields will pass through automatically since they match the GraphQL names. + +- [ ] **Step 2: Verify frontend compiles** + +Run: `cd helix-engage && npm run build` +Expected: Clean compilation (may have warnings, no errors) + +- [ ] **Step 3: Commit** + +```bash +git add helix-engage/src/hooks/use-worklist.ts +git commit -m "feat: add callback tracking fields to MissedCall type" +``` + +--- + +## Task 6: Frontend — Missed Call Status Sub-Tabs + +**Files:** +- Modify: `helix-engage/src/components/call-desk/worklist-panel.tsx` + +This is the largest frontend change. The worklist panel currently has tabs: All | Missed | Callbacks | Follow-ups. We add status sub-tabs under the Missed tab. + +- [ ] **Step 1: Add sub-tab state and filtering logic** + +At the top of the `WorklistPanel` component, add a sub-tab state: + +```typescript +type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; +const [missedSubTab, setMissedSubTab] = useState('pending'); +``` + +Add a filtering function that splits missed calls by `callbackstatus`: + +```typescript +const missedByStatus = useMemo(() => { + const pending = missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus); + const attempted = missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'); + const completed = missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'); + const invalid = missedCalls.filter(c => c.callbackstatus === 'INVALID'); + return { pending, attempted, completed, invalid }; +}, [missedCalls]); +``` + +- [ ] **Step 2: Render sub-tabs when Missed tab is active** + +Inside the Missed tab content section, add sub-tab buttons before the table: + +```tsx +{activeTab === 'missed' && ( +
+ {(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => ( + + ))} +
+)} +``` + +- [ ] **Step 3: Use filtered missed calls in the table rows** + +Where the component currently maps `missedCalls` to table rows for the Missed tab, replace it to use `missedByStatus[missedSubTab]` instead. The active sub-tab determines which missed calls to display. + +Also add the `missedcallcount` badge and `callsourcenumber` to the row rendering: + +```tsx +{mc.missedcallcount && mc.missedcallcount > 1 && ( + + {mc.missedcallcount}x + +)} +{mc.callsourcenumber && ( + {mc.callsourcenumber} +)} +``` + +- [ ] **Step 4: Verify frontend compiles** + +Run: `cd helix-engage && npm run build` +Expected: Clean compilation + +- [ ] **Step 5: Commit** + +```bash +git add helix-engage/src/components/call-desk/worklist-panel.tsx +git commit -m "feat: add status sub-tabs to missed call worklist panel" +``` + +--- + +## Task 7: Frontend — Pass missedCallId Through Disposition Flow + +**Files:** +- Modify: `helix-engage/src/pages/call-desk.tsx` +- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx` + +- [ ] **Step 1: Track the active missed call ID in call-desk page** + +Read `src/pages/call-desk.tsx` to understand how it passes props to `ActiveCallCard`. Add state to track when the current call is a missed call callback: + +```typescript +const [activeMissedCallId, setActiveMissedCallId] = useState(null); +``` + +When the agent clicks call-back on a missed call from the worklist, set this ID. Pass it to `ActiveCallCard`: + +```tsx + +``` + +The worklist panel's click-to-call handler should: +1. Call `PATCH /api/worklist/missed-queue/:id/status` with `{ status: 'CALLBACK_ATTEMPTED' }` +2. Set `activeMissedCallId` to the missed call's ID +3. Initiate the outbound call via SIP + +- [ ] **Step 2: Update ActiveCallCard to pass missedCallId in disposition** + +Modify `helix-engage/src/components/call-desk/active-call-card.tsx`: + +Add `missedCallId` to the props interface: + +```typescript +interface ActiveCallCardProps { + lead: Lead | null; + callerPhone: string; + missedCallId?: string | null; +} +``` + +Update the destructured props: + +```typescript +export const ActiveCallCard = ({ lead, callerPhone, missedCallId }: ActiveCallCardProps) => { +``` + +In `handleDisposition` (line 62-72), add `missedCallId` to the dispose payload: + +```typescript +apiClient.post('/api/ozonetel/dispose', { + ucid: callUcid, + disposition, + callerPhone, + direction: callDirectionRef.current, + durationSec: callDuration, + leadId: lead?.id ?? null, + notes, + missedCallId: missedCallId ?? undefined, +}).catch((err) => console.warn('Disposition failed:', err)); +``` + +- [ ] **Step 3: Clear activeMissedCallId after disposition completes** + +In `call-desk.tsx`, when the call ends and disposition is done (when `ActiveCallCard` signals completion), reset: + +```typescript +setActiveMissedCallId(null); +``` + +- [ ] **Step 4: Verify frontend compiles** + +Run: `cd helix-engage && npm run build` +Expected: Clean compilation + +- [ ] **Step 5: Commit** + +```bash +git add helix-engage/src/pages/call-desk.tsx helix-engage/src/components/call-desk/active-call-card.tsx +git commit -m "feat: pass missedCallId through disposition flow for callback status tracking" +``` + +--- + +## Task 8: Login Page Redesign + +**Files:** +- Modify: `helix-engage/src/pages/login.tsx` + +- [ ] **Step 1: Rewrite login page layout** + +Replace the entire JSX return in `login.tsx` (from line ~89 onward). Keep ALL the logic (handleSubmit, state, useEffect for remember-me, error handling) — only change the layout: + +```tsx +return ( +
+ {/* Login Card */} +
+ {/* Logo */} +
+ Helix Engage +

Sign in to Helix Engage

+

Global Hospital

+
+ + {/* Google Sign In */} + + + {/* Divider */} +
+
+ or continue with +
+
+ + {/* Form */} +
+ {error && ( +
{error}
+ )} + +
+ + setEmail(e.target.value)} + placeholder="you@company.com" + className="w-full px-3 py-2 border border-primary rounded-lg text-sm text-primary placeholder:text-placeholder focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + className="w-full px-3 py-2 border border-primary rounded-lg text-sm text-primary placeholder:text-placeholder focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand pr-10" + required + /> + +
+
+ +
+ + + Forgot password? + +
+ + +
+
+ + {/* Footer */} +

Powered by FortyTwo

+
+); +``` + +**Notes**: +- Check which imports are used — remove unused imports from the old layout (feature card icons, etc.). Keep `FontAwesomeIcon`, `faEye`, `faEyeSlash`. +- Add `showPassword` state if not already present: `const [showPassword, setShowPassword] = useState(false);` +- The existing login page uses `Input`, `Button`, `Checkbox` components from the Untitled UI library. **Prefer reusing these components** over raw HTML elements for consistency. Read the existing login page to see which components are already imported and adapt the template above to use them (e.g., `` instead of ``, `` instead of ` +
+``` + +- [ ] **Step 2: Check other forms** + +Read `appointment-form.tsx`, `enquiry-form.tsx`, `transfer-dialog.tsx` — check if their buttons also use `w-full`. Only fix the ones that do. The spec review found that these may already be content-width. + +- [ ] **Step 3: Verify frontend compiles** + +Run: `cd helix-engage && npm run build` +Expected: Clean compilation + +- [ ] **Step 4: Commit** + +```bash +git add helix-engage/src/components/call-desk/disposition-form.tsx +git commit -m "fix: use content-width buttons in call desk forms" +``` + +--- + +## Task 10: Deploy + Verify + +**Files:** None (deploy only) + +- [ ] **Step 1: Build and deploy sidecar** + +```bash +cd helix-engage-server && npm run build && \ +tar czf /tmp/sidecar-src.tar.gz --exclude=node_modules --exclude=.git --exclude=dist . && \ +tar czf /tmp/sidecar-dist.tar.gz -C dist . && \ +scp -i ~/Downloads/fortytwoai_hostinger /tmp/sidecar-src.tar.gz /tmp/sidecar-dist.tar.gz root@148.230.67.184:/tmp/ && \ +ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker cp /tmp/sidecar-src.tar.gz fortytwo-staging-sidecar-1:/tmp/ && docker cp /tmp/sidecar-dist.tar.gz fortytwo-staging-sidecar-1:/tmp/ && docker exec fortytwo-staging-sidecar-1 sh -c 'cd /app && tar xzf /tmp/sidecar-src.tar.gz && tar xzf /tmp/sidecar-dist.tar.gz -C dist' && cd /opt/fortytwo && docker compose restart sidecar" +``` + +- [ ] **Step 2: Build and deploy frontend** + +```bash +cd helix-engage && \ +VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \ +VITE_SIDECAR_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 czf /tmp/frontend-deploy.tar.gz -C dist . && \ +scp -i ~/Downloads/fortytwoai_hostinger /tmp/frontend-deploy.tar.gz root@148.230.67.184:/tmp/ && \ +ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "cd /opt/fortytwo/helix-engage-frontend && rm -rf * && tar xzf /tmp/frontend-deploy.tar.gz" +``` + +- [ ] **Step 3: Verify sidecar ingestion is running** + +```bash +ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 20 2>&1 | grep -i 'missed\|ingest\|queue'" +``` + +Expected: Log lines showing ingestion polling started + +- [ ] **Step 4: Verify login page** + +Open the deployed frontend URL. Login page should show centered white card on blue background. + +- [ ] **Step 5: Verify missed call sub-tabs** + +Log in as a CC agent, navigate to Call Desk. The Missed tab should show sub-tabs: Pending | Attempted | Completed | Invalid. + +- [ ] **Step 6: Commit any deployment fixes** + +If any issues found during verification, fix and recommit. diff --git a/docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md b/docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md new file mode 100644 index 0000000..ce158ff --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md @@ -0,0 +1,242 @@ +# 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 calls +- `agentName` — tracks which agent is assigned +- `disposition` — records callback outcome +- `callerNumber` — caller's phone (PHONES type, accessed as `callerNumber { primaryPhoneNumber }`) +- `startedAt` — when the call was missed +- `leadId` — 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 endpoints +- `src/worklist/worklist.module.ts` — Register MissedQueueService + +**Auth model**: +- `GET /api/missed-queue` and `PATCH /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) + +1. Call `OzonetelAgentService.getAbandonCalls()` with `fromTime`/`toTime` limited to the **last 5 minutes** (the method already supports these parameters). This prevents re-processing the entire day's abandon calls on service restart. +2. Normalize caller phone numbers to `+91XXXXXXXXXX` format before any query or write (Ozonetel may return numbers in varying formats like `009919876543210` or `9876543210`). +3. For each abandoned call: + - Extract `callerID` (phone number, normalized) and `did` (source number) + - Query platform: `calls(filter: { callerNumber: { primaryPhoneNumber: { eq: "" } }, callbackstatus: { eq: PENDING_CALLBACK } })` + - **Match found** → `updateCall`: increment `missedcallcount`, update `startedAt` to latest timestamp + - **No match** → `createCall`: + ```graphql + mutation { createCall(data: { + callStatus: MISSED, + direction: INBOUND, + callerNumber: { primaryPhoneNumber: "", primaryPhoneCallingCode: "+91" }, + callsourcenumber: "", + callbackstatus: PENDING_CALLBACK, + missedcallcount: 1, + startedAt: "" + }) { id } } + ``` +4. Track ingested Ozonetel `monitorUCID` values 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: + +1. **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. +2. **Manual state change** (`POST /api/ozonetel/agent-state`): When an agent manually toggles to Ready via AgentStatusToggle. + +In both cases, call `MissedQueueService.assignNext(agentName)`: +1. Query platform: oldest Call with `callbackstatus: PENDING_CALLBACK` and `agentName` is null/empty, ordered by `startedAt: AscNullsLast` +2. If found → `updateCall` setting `agentName` to the available agent +3. Use optimistic concurrency: if the update fails (another agent claimed it first), retry with the next oldest call +4. 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: + +```graphql +calls(first: 20, filter: { + agentName: { eq: "" }, + 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`, `callbackattemptedat` to `MissedCall` type +- 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 `AscNullsLast` sort) +- 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: +1. Frontend calls `PATCH /api/missed-queue/:id/status` with `{ status: 'CALLBACK_ATTEMPTED' }` +2. Normal outbound call flow begins via SIP +3. After call ends → disposition form → disposition submitted → sidecar maps disposition to final `callbackstatus` and 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 (confirmed `w-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` (not `callbackStatus`) +- `callsourcenumber` (not `callSourceNumber`) +- `missedcallcount` (not `missedCallCount`) +- `callbackattemptedat` (not `callbackAttemptedAt`) + +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` 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 `callsourcenumber` updates 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_MS` env var. diff --git a/src/components/application/app-navigation/base-components/nav-account-card.tsx b/src/components/application/app-navigation/base-components/nav-account-card.tsx index fc09efe..9f84595 100644 --- a/src/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/src/components/application/app-navigation/base-components/nav-account-card.tsx @@ -71,17 +71,21 @@ export const NavAccountMenu = ({ ref={dialogRef} className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)} > -
-
- - - -
-
+ {({ close }) => ( + <> +
+
+ + + { close(); onForceReady?.(); }} /> +
+
-
- -
+
+ { close(); onSignOut?.(); }} /> +
+ + )} ); }; diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index bbd005f..e7e3fcd 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -26,6 +26,8 @@ type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done'; interface ActiveCallCardProps { lead: Lead | null; callerPhone: string; + missedCallId?: string | null; + onCallComplete?: () => void; } const formatDuration = (seconds: number): string => { @@ -34,7 +36,7 @@ const formatDuration = (seconds: number): string => { return `${m}:${s.toString().padStart(2, '0')}`; }; -export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { +export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const setCallState = useSetAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); @@ -68,6 +70,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { durationSec: callDuration, leadId: lead?.id ?? null, notes, + missedCallId: missedCallId ?? undefined, }).catch((err) => console.warn('Disposition failed:', err)); } @@ -117,6 +120,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { setCallerNumber(null); setCallUcid(null); setOutboundPending(false); + onCallComplete?.(); }; // Outbound ringing — agent initiated the call diff --git a/src/components/call-desk/disposition-form.tsx b/src/components/call-desk/disposition-form.tsx index 479483e..c0ddf66 100644 --- a/src/components/call-desk/disposition-form.tsx +++ b/src/components/call-desk/disposition-form.tsx @@ -94,19 +94,21 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor rows={3} /> - +
+ +
); }; diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index 903a649..ca08fa4 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -45,8 +45,14 @@ type MissedCall = { startedAt: string | null; leadId: string | null; disposition: string | null; + callbackstatus: string | null; + callsourcenumber: string | null; + missedcallcount: number | null; + callbackattemptedat: string | null; }; +type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; + interface WorklistPanelProps { missedCalls: MissedCall[]; followUps: WorklistFollowUp[]; @@ -136,25 +142,27 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea for (const call of missedCalls) { const phone = call.callerNumber?.[0]; + const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : ''; + const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : ''; rows.push({ id: `mc-${call.id}`, type: 'missed', priority: 'HIGH', - name: phone ? formatPhone(phone) : 'Unknown', + name: (phone ? formatPhone(phone) : 'Unknown') + countBadge, phone: phone ? formatPhone(phone) : '', phoneRaw: phone?.number ?? '', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', typeLabel: 'Missed Call', reason: call.startedAt - ? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}` + ? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}${sourceSuffix}` : 'Missed call', createdAt: call.createdAt, - taskState: 'PENDING', + taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', leadId: call.leadId, originalLead: null, - lastContactedAt: call.startedAt ?? call.createdAt, + lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt, contactAttempts: 0, - source: null, + source: call.callsourcenumber ?? null, lastDisposition: call.disposition ?? null, }); } @@ -227,15 +235,29 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => { const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); + const [missedSubTab, setMissedSubTab] = useState('pending'); + + const missedByStatus = useMemo(() => ({ + pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus), + attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'), + completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'), + invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'), + }), [missedCalls]); const allRows = useMemo( () => buildRows(missedCalls, followUps, leads), [missedCalls, followUps, leads], ); + // Build rows from sub-tab filtered missed calls when on missed tab + const missedSubTabRows = useMemo( + () => buildRows(missedByStatus[missedSubTab], [], []), + [missedByStatus, missedSubTab], + ); + const filteredRows = useMemo(() => { let rows = allRows; - if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed'); + if (tab === 'missed') rows = missedSubTabRows; else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback'); else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); @@ -318,6 +340,31 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect + {/* Missed call status sub-tabs */} + {tab === 'missed' && ( +
+ {(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => ( + + ))} +
+ )} + {filteredRows.length === 0 ? (

diff --git a/src/hooks/use-worklist.ts b/src/hooks/use-worklist.ts index 9971e26..8bcd114 100644 --- a/src/hooks/use-worklist.ts +++ b/src/hooks/use-worklist.ts @@ -15,6 +15,10 @@ type MissedCall = { disposition: string | null; callNotes: string | null; leadId: string | null; + callbackstatus: string | null; + callsourcenumber: string | null; + missedcallcount: number | null; + callbackattemptedat: string | null; }; type WorklistFollowUp = { diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index e7d95b4..f9fac0f 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -21,6 +21,7 @@ export const CallDeskPage = () => { const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist(); const [selectedLead, setSelectedLead] = useState(null); const [contextOpen, setContextOpen] = useState(true); + const [activeMissedCallId, setActiveMissedCallId] = useState(null); const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed'; @@ -62,7 +63,7 @@ export const CallDeskPage = () => { {/* Active call */} {isInCall && (

- + setActiveMissedCallId(null)} />
)} diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 1b9e659..0310525 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -8,22 +8,6 @@ import { SocialButton } from '@/components/base/buttons/social-button'; import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Input } from '@/components/base/input/input'; -const features = [ - { - title: 'Unified Lead Inbox', - description: 'All channels in one workspace', - }, - { - title: 'Campaign Intelligence', - description: 'Real-time performance tracking', - }, - { - title: 'Speed to Contact', - description: 'Automated assignment and outreach', - }, -]; - - export const LoginPage = () => { const { loginWithUser } = useAuth(); const navigate = useNavigate(); @@ -87,114 +71,53 @@ export const LoginPage = () => { }; return ( -
- {/* Left panel — 60% — hidden on mobile */} -
- {/* Abstract corner gradients */} -