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

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() and updateStatus() 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:

  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:

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