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