Files
helix-engage/docs/superpowers/plans/2026-03-22-phase2-missed-call-queue-login-redesign.md
saridsa2 744a91a1ff feat: Phase 2 — missed call queue, login redesign, button fix
- 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>
2026-03-23 09:16:53 +05:30

1042 lines
40 KiB
Markdown

# 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<string>();
private assignmentMutex = false;
constructor(
private readonly config: ConfigService,
private readonly platform: PlatformGraphqlService,
private readonly ozonetel: OzonetelAgentService,
) {
this.pollIntervalMs = this.config.get<number>('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<any | null> {
// Implemented in Task 3
return null;
}
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
// Implemented in Task 4
return {};
}
async getMissedQueue(agentName: string, authHeader: string): Promise<any> {
// 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<T>(query: string, variables?: Record<string, any>): Promise<T> {
// to
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
```
- [ ] **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<any>(
`{ 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<any>(
`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<any>(
`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<any | null> {
// 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<any>(
`{ 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<any>(
`{ 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<any>(
`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<any>(
`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<string, string> = {
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<any>(
`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<any>(query('PENDING_CALLBACK'), undefined, authHeader),
this.platform.queryWithAuth<any>(query('CALLBACK_ATTEMPTED'), undefined, authHeader),
this.platform.queryWithAuth<any>(query('CALLBACK_COMPLETED'), undefined, authHeader),
this.platform.queryWithAuth<any>(query('INVALID'), undefined, authHeader),
this.platform.queryWithAuth<any>(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<any> {
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<string, any> = { 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<any>(
`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<any[]> {
try {
const data = await this.platform.queryWithAuth<any>(
`{ 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<MissedSubTab>('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' && (
<div className="flex gap-1 px-3 pb-2">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
onClick={() => setMissedSubTab(sub)}
className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
missedSubTab === sub
? 'bg-brand-50 text-brand-700 border border-brand-200'
: 'text-tertiary hover:text-secondary hover:bg-secondary',
)}
>
{sub}
{sub === 'pending' && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
{missedByStatus.pending.length}
</span>
)}
</button>
))}
</div>
)}
```
- [ ] **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 && (
<Badge size="sm" color="warning" type="pill-color">
{mc.missedcallcount}x
</Badge>
)}
{mc.callsourcenumber && (
<span className="text-xs text-tertiary">{mc.callsourcenumber}</span>
)}
```
- [ ] **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<string | null>(null);
```
When the agent clicks call-back on a missed call from the worklist, set this ID. Pass it to `ActiveCallCard`:
```tsx
<ActiveCallCard lead={matchedLead} callerPhone={callerPhone} missedCallId={activeMissedCallId} />
```
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 (
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
{/* Login Card */}
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<img src="/helix-logo-white.svg" alt="Helix Engage" className="h-10 mb-2 invert" />
<h1 className="text-xl font-semibold text-primary">Sign in to Helix Engage</h1>
<p className="text-sm text-tertiary mt-1">Global Hospital</p>
</div>
{/* Google Sign In */}
<button className="w-full flex items-center justify-center gap-3 px-4 py-2.5 border border-secondary rounded-lg hover:bg-secondary transition duration-100 ease-linear mb-4">
<svg className="size-5" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
<span className="text-sm font-medium text-secondary">Sign in with Google</span>
</button>
{/* Divider */}
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-tertiary" />
<span className="text-xs font-medium text-quaternary uppercase tracking-wider">or continue with</span>
<div className="flex-1 h-px bg-tertiary" />
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-error-secondary rounded-lg text-sm text-error-primary">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-secondary mb-1.5">Email</label>
<input
type="email"
value={email}
onChange={e => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-1.5">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => 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
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-quaternary hover:text-secondary"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
</button>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={e => setRememberMe(e.target.checked)}
className="size-4 rounded border-primary text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-secondary">Remember me</span>
</label>
<a href="#" className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover">
Forgot password?
</a>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-brand-solid hover:bg-brand-solid_hover text-white font-medium rounded-lg text-sm transition duration-100 ease-linear disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
{/* Footer */}
<p className="mt-6 text-xs text-primary_on-brand opacity-60">Powered by FortyTwo</p>
</div>
);
```
**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., `<Input label="Email" ...>` instead of `<input ...>`, `<Button color="primary">Sign in</Button>` instead of `<button ...>`).
- The existing page uses `Button` with `color="secondary"` for Google sign-in — reuse that pattern.
- [ ] **Step 2: Verify the logo path exists**
Check if `/helix-logo-white.svg` exists in `helix-engage/public/`. If the logo uses a different path or component, adjust the `<img>` src accordingly. Read the old login page to find the actual logo import.
- [ ] **Step 3: Verify frontend compiles**
Run: `cd helix-engage && npm run build`
Expected: Clean compilation
- [ ] **Step 4: Commit**
```bash
git add helix-engage/src/pages/login.tsx
git commit -m "feat: redesign login page — centered white card on blue background"
```
---
## Task 9: Button Width Fix
**Files:**
- Modify: `helix-engage/src/components/call-desk/disposition-form.tsx`
- Possibly modify: other call desk forms (verify first)
- [ ] **Step 1: Fix disposition form button**
Read `disposition-form.tsx` and find the submit button (around line 97-109). If it uses `w-full` or the parent container forces full width, change the button container to:
```tsx
<div className="flex justify-end pt-3">
<Button ...props (remove w-full if present)>
Save Disposition
</Button>
</div>
```
- [ ] **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.