- 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>
40 KiB
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
// 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:
// 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:
// 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:
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
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:
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
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:
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:
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:
import { MissedQueueService } from '../worklist/missed-queue.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
Add to constructor (after private readonly config: ConfigService):
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:
// 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:
// 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
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()andupdateStatus()in the service
Replace the placeholder methods in missed-queue.service.ts:
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:
import { Controller, Get, Patch, Post, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
import { MissedQueueService } from './missed-queue.service';
Add MissedQueueService to constructor:
constructor(
private readonly worklist: WorklistService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService,
) {}
Add these endpoints after the existing @Get():
@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:
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
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:
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
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:
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
Add a filtering function that splits missed calls by callbackstatus:
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:
{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:
{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
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:
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:
<ActiveCallCard lead={matchedLead} callerPhone={callerPhone} missedCallId={activeMissedCallId} />
The worklist panel's click-to-call handler should:
- Call
PATCH /api/worklist/missed-queue/:id/statuswith{ status: 'CALLBACK_ATTEMPTED' } - Set
activeMissedCallIdto the missed call's ID - 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:
interface ActiveCallCardProps {
lead: Lead | null;
callerPhone: string;
missedCallId?: string | null;
}
Update the destructured props:
export const ActiveCallCard = ({ lead, callerPhone, missedCallId }: ActiveCallCardProps) => {
In handleDisposition (line 62-72), add missedCallId to the dispose payload:
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:
setActiveMissedCallId(null);
- Step 4: Verify frontend compiles
Run: cd helix-engage && npm run build
Expected: Clean compilation
- Step 5: Commit
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:
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
showPasswordstate if not already present:const [showPassword, setShowPassword] = useState(false); -
The existing login page uses
Input,Button,Checkboxcomponents 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
Buttonwithcolor="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
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:
<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
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
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
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
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.