mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- 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>
1042 lines
40 KiB
Markdown
1042 lines
40 KiB
Markdown
# Phase 2: Missed Call Queue + Login Redesign + Button Fix — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Implement missed call queue with FIFO auto-assignment, dedup, and callback SLA tracking; redesign the login page; fix button widths in call desk forms.
|
|
|
|
**Architecture:** Sidecar polls Ozonetel abandon calls every 30s, deduplicates by phone number, writes/updates Call records on the platform via GraphQL. Auto-assignment triggered on agent Ready state (via dispose or manual toggle). Frontend shows status-tabbed missed call view in the worklist panel.
|
|
|
|
**Tech Stack:** NestJS sidecar (TypeScript), Fortytwo platform GraphQL API, React + Tailwind frontend
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### Sidecar (`helix-engage-server/src/`)
|
|
|
|
| File | Action | Responsibility |
|
|
|------|--------|----------------|
|
|
| `worklist/missed-queue.service.ts` | Create | Ingestion polling, dedup, auto-assignment, status updates |
|
|
| `worklist/worklist.controller.ts` | Modify | Add missed queue endpoints (GET, PATCH, POST assign) |
|
|
| `worklist/worklist.service.ts` | Modify | Add new fields to missed call query, change sort order to FIFO |
|
|
| `worklist/worklist.module.ts` | Modify | Register MissedQueueService, forwardRef OzonetelAgentModule |
|
|
| `ozonetel/ozonetel-agent.controller.ts` | Modify | Hook auto-assignment into dispose + agent-state endpoints, add `missedCallId` to dispose |
|
|
| `ozonetel/ozonetel-agent.module.ts` | Modify | forwardRef WorklistModule for MissedQueueService access |
|
|
| `platform/platform-graphql.service.ts` | Modify | Make `query()` method public (currently private) |
|
|
| `config/configuration.ts` | Modify | Add `MISSED_QUEUE_POLL_INTERVAL_MS` env var |
|
|
|
|
### Frontend (`helix-engage/src/`)
|
|
|
|
| File | Action | Responsibility |
|
|
|------|--------|----------------|
|
|
| `hooks/use-worklist.ts` | Modify | Add callback fields to MissedCall type, transform data |
|
|
| `components/call-desk/worklist-panel.tsx` | Modify | Add status sub-tabs under Missed tab |
|
|
| `components/call-desk/active-call-card.tsx` | Modify | Pass `missedCallId` to dispose endpoint when callback |
|
|
| `components/call-desk/disposition-form.tsx` | Modify | Fix button width |
|
|
| `pages/login.tsx` | Rewrite | Centered white card on blue background |
|
|
| `pages/call-desk.tsx` | Modify | Track active missed call ID for disposition flow |
|
|
|
|
---
|
|
|
|
## Task 1: MissedQueueService — Phone Normalization + Core Service Skeleton
|
|
|
|
**Files:**
|
|
- Create: `helix-engage-server/src/worklist/missed-queue.service.ts`
|
|
|
|
- [ ] **Step 1: Create the service with phone normalization utility**
|
|
|
|
```typescript
|
|
// helix-engage-server/src/worklist/missed-queue.service.ts
|
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
|
|
|
// Normalize phone to +91XXXXXXXXXX format
|
|
export function normalizePhone(raw: string): string {
|
|
let digits = raw.replace(/[^0-9]/g, '');
|
|
// Strip leading country code variations: 0091, 91, 0
|
|
if (digits.startsWith('0091')) digits = digits.slice(4);
|
|
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
|
|
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
|
|
return `+91${digits.slice(-10)}`;
|
|
}
|
|
|
|
@Injectable()
|
|
export class MissedQueueService implements OnModuleInit {
|
|
private readonly logger = new Logger(MissedQueueService.name);
|
|
private readonly pollIntervalMs: number;
|
|
private readonly processedUcids = new Set<string>();
|
|
private assignmentMutex = false;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly platform: PlatformGraphqlService,
|
|
private readonly ozonetel: OzonetelAgentService,
|
|
) {
|
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
|
}
|
|
|
|
onModuleInit() {
|
|
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
|
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
|
}
|
|
|
|
async ingest(): Promise<{ created: number; updated: number }> {
|
|
// Implemented in Task 2
|
|
return { created: 0, updated: 0 };
|
|
}
|
|
|
|
async assignNext(agentName: string): Promise<any | null> {
|
|
// Implemented in Task 3
|
|
return null;
|
|
}
|
|
|
|
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
|
// Implemented in Task 4
|
|
return {};
|
|
}
|
|
|
|
async getMissedQueue(agentName: string, authHeader: string): Promise<any> {
|
|
// Implemented in Task 4
|
|
return { pending: [], attempted: [], completed: [], invalid: [] };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Make `PlatformGraphqlService.query()` public**
|
|
|
|
The `query()` method at `platform/platform-graphql.service.ts:17` is `private`. The `MissedQueueService` needs to call it for server-to-server operations (ingestion, assignment). Change `private` to `public`:
|
|
|
|
```typescript
|
|
// Line 17: change from
|
|
private async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
|
// to
|
|
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
|
```
|
|
|
|
- [ ] **Step 3: Register in WorklistModule with forwardRef**
|
|
|
|
Modify `helix-engage-server/src/worklist/worklist.module.ts`:
|
|
|
|
```typescript
|
|
// Add imports at top
|
|
import { forwardRef } from '@nestjs/common';
|
|
import { MissedQueueService } from './missed-queue.service';
|
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
|
|
|
// Update @Module decorator:
|
|
@Module({
|
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
|
providers: [WorklistService, MissedQueueService],
|
|
exports: [MissedQueueService],
|
|
})
|
|
```
|
|
|
|
Check that `OzonetelAgentModule` exports `OzonetelAgentService`. Read `helix-engage-server/src/ozonetel/ozonetel-agent.module.ts` to verify — if not exported, add it.
|
|
|
|
- [ ] **Step 4: Add config entry**
|
|
|
|
Modify `helix-engage-server/src/config/configuration.ts` — add inside the returned config object:
|
|
|
|
```typescript
|
|
missedQueue: {
|
|
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Verify sidecar compiles**
|
|
|
|
Run: `cd helix-engage-server && npm run build`
|
|
Expected: Clean compilation, no errors
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/worklist/worklist.module.ts helix-engage-server/src/platform/platform-graphql.service.ts helix-engage-server/src/config/configuration.ts
|
|
git commit -m "feat: add MissedQueueService skeleton with phone normalization"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: MissedQueueService — Ingestion + Dedup
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts`
|
|
|
|
- [ ] **Step 1: Implement the `ingest()` method**
|
|
|
|
Replace the placeholder `ingest()` method in `missed-queue.service.ts`:
|
|
|
|
```typescript
|
|
async ingest(): Promise<{ created: number; updated: number }> {
|
|
let created = 0;
|
|
let updated = 0;
|
|
|
|
// Query last 5 minutes of abandon calls
|
|
const now = new Date();
|
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
const format = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
|
|
|
|
let abandonCalls: any[];
|
|
try {
|
|
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: format(fiveMinAgo), toTime: format(now) });
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
|
return { created: 0, updated: 0 };
|
|
}
|
|
|
|
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
|
|
|
for (const call of abandonCalls) {
|
|
const ucid = call.monitorUCID;
|
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
|
this.processedUcids.add(ucid);
|
|
|
|
const phone = normalizePhone(call.callerID || '');
|
|
if (!phone || phone.length < 13) continue; // +91 + 10 digits = 13
|
|
|
|
const did = call.did || '';
|
|
const callTime = call.callTime || new Date().toISOString();
|
|
|
|
try {
|
|
// Check for existing PENDING_CALLBACK record with same phone
|
|
const existing = await this.platform.query<any>(
|
|
`{ calls(first: 1, filter: {
|
|
callbackstatus: { eq: PENDING_CALLBACK },
|
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
|
}) { edges { node { id missedcallcount } } } }`,
|
|
);
|
|
|
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
|
|
|
if (existingNode) {
|
|
// Dedup: increment count, update timestamp
|
|
const newCount = (existingNode.missedcallcount || 1) + 1;
|
|
await this.platform.query<any>(
|
|
`mutation { updateCall(id: "${existingNode.id}", data: {
|
|
missedcallcount: ${newCount},
|
|
startedAt: "${callTime}",
|
|
callsourcenumber: "${did}"
|
|
}) { id } }`,
|
|
);
|
|
updated++;
|
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}`);
|
|
} else {
|
|
// New missed call record
|
|
await this.platform.query<any>(
|
|
`mutation { createCall(data: {
|
|
callStatus: MISSED,
|
|
direction: INBOUND,
|
|
callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" },
|
|
callsourcenumber: "${did}",
|
|
callbackstatus: PENDING_CALLBACK,
|
|
missedcallcount: 1,
|
|
startedAt: "${callTime}"
|
|
}) { id } }`,
|
|
);
|
|
created++;
|
|
this.logger.log(`Created missed call record for ${phone}`);
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
|
}
|
|
}
|
|
|
|
// Trim processedUcids set to prevent unbounded growth (keep last 500)
|
|
if (this.processedUcids.size > 500) {
|
|
const arr = Array.from(this.processedUcids);
|
|
this.processedUcids.clear();
|
|
arr.slice(-200).forEach(u => this.processedUcids.add(u));
|
|
}
|
|
|
|
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
|
return { created, updated };
|
|
}
|
|
```
|
|
|
|
**Note**: `this.platform.query()` uses the server API key (not user JWT). The `getAbandonCalls()` method accepts an options object: `{ fromTime?, toTime?, campaignName? }` (see `ozonetel-agent.service.ts:263`).
|
|
|
|
- [ ] **Step 2: Verify sidecar compiles**
|
|
|
|
Run: `cd helix-engage-server && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add helix-engage-server/src/worklist/missed-queue.service.ts
|
|
git commit -m "feat: implement missed call ingestion with dedup and phone normalization"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: MissedQueueService — Auto-Assignment
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts`
|
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
|
|
|
- [ ] **Step 1: Implement `assignNext()` in the service**
|
|
|
|
Replace the placeholder `assignNext()` method:
|
|
|
|
```typescript
|
|
async assignNext(agentName: string): Promise<any | null> {
|
|
// Simple mutex to prevent race conditions
|
|
if (this.assignmentMutex) return null;
|
|
this.assignmentMutex = true;
|
|
|
|
try {
|
|
// Find oldest unassigned PENDING_CALLBACK call
|
|
const result = await this.platform.query<any>(
|
|
`{ calls(first: 1, filter: {
|
|
callbackstatus: { eq: PENDING_CALLBACK },
|
|
agentName: { eq: "" }
|
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
|
edges { node {
|
|
id callerNumber { primaryPhoneNumber }
|
|
startedAt callsourcenumber missedcallcount
|
|
} }
|
|
} }`,
|
|
);
|
|
|
|
const call = result?.calls?.edges?.[0]?.node;
|
|
if (!call) {
|
|
// Also check for null agentName (not just empty string)
|
|
const result2 = await this.platform.query<any>(
|
|
`{ calls(first: 1, filter: {
|
|
callbackstatus: { eq: PENDING_CALLBACK },
|
|
agentName: { is: NULL }
|
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
|
edges { node {
|
|
id callerNumber { primaryPhoneNumber }
|
|
startedAt callsourcenumber missedcallcount
|
|
} }
|
|
} }`,
|
|
);
|
|
const call2 = result2?.calls?.edges?.[0]?.node;
|
|
if (!call2) return null;
|
|
|
|
await this.platform.query<any>(
|
|
`mutation { updateCall(id: "${call2.id}", data: { agentName: "${agentName}" }) { id } }`,
|
|
);
|
|
this.logger.log(`Assigned missed call ${call2.id} to ${agentName}`);
|
|
return call2;
|
|
}
|
|
|
|
await this.platform.query<any>(
|
|
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
|
);
|
|
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
|
return call;
|
|
} catch (err) {
|
|
this.logger.warn(`Assignment failed: ${err}`);
|
|
return null;
|
|
} finally {
|
|
this.assignmentMutex = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire OzonetelAgentModule ↔ WorklistModule with forwardRef**
|
|
|
|
**This must be done BEFORE adding injections to the controller**, otherwise NestJS cannot resolve dependencies.
|
|
|
|
In `ozonetel-agent.module.ts`:
|
|
```typescript
|
|
import { forwardRef } from '@nestjs/common';
|
|
import { WorklistModule } from '../worklist/worklist.module';
|
|
|
|
@Module({
|
|
imports: [forwardRef(() => WorklistModule)],
|
|
// ... keep existing controllers, providers, exports
|
|
})
|
|
```
|
|
|
|
Verify `worklist.module.ts` already has `forwardRef(() => OzonetelAgentModule)` from Task 1 Step 3.
|
|
|
|
- [ ] **Step 3: Hook auto-assignment into the dispose endpoint**
|
|
|
|
Modify `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`.
|
|
|
|
Add imports at top:
|
|
|
|
```typescript
|
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
```
|
|
|
|
Add to constructor (after `private readonly config: ConfigService`):
|
|
|
|
```typescript
|
|
private readonly missedQueue: MissedQueueService,
|
|
private readonly platform: PlatformGraphqlService,
|
|
```
|
|
|
|
Then at the END of the dispose handler (after line ~133, after the try-catch for `setDisposition`), add:
|
|
|
|
```typescript
|
|
// Handle missed call callback status update
|
|
const missedCallId = body.missedCallId;
|
|
if (missedCallId) {
|
|
const statusMap: Record<string, string> = {
|
|
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
|
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
|
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
|
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
|
WRONG_NUMBER: 'WRONG_NUMBER',
|
|
// NO_ANSWER stays CALLBACK_ATTEMPTED — agent can retry
|
|
};
|
|
const newStatus = statusMap[body.disposition];
|
|
if (newStatus) {
|
|
try {
|
|
await this.platform.query<any>(
|
|
`mutation { updateCall(id: "${missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to update missed call status: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-assign next missed call to this agent
|
|
try {
|
|
await this.missedQueue.assignNext(this.defaultAgentId);
|
|
} catch (err) {
|
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Hook auto-assignment into agent-state endpoint**
|
|
|
|
In the same controller, at the end of the agent-state handler (after line ~76), add:
|
|
|
|
```typescript
|
|
// Auto-assign missed call when agent goes Ready
|
|
if (body.state === 'Ready') {
|
|
try {
|
|
const assigned = await this.missedQueue.assignNext(this.agentId);
|
|
if (assigned) {
|
|
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned };
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify sidecar compiles**
|
|
|
|
Run: `cd helix-engage-server && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts helix-engage-server/src/ozonetel/ozonetel-agent.module.ts helix-engage-server/src/worklist/worklist.module.ts
|
|
git commit -m "feat: auto-assign missed calls on agent Ready state"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Missed Queue Endpoints + Worklist Update
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage-server/src/worklist/missed-queue.service.ts`
|
|
- Modify: `helix-engage-server/src/worklist/worklist.controller.ts`
|
|
- Modify: `helix-engage-server/src/worklist/worklist.service.ts`
|
|
|
|
- [ ] **Step 1: Implement `getMissedQueue()` and `updateStatus()` in the service**
|
|
|
|
Replace the placeholder methods in `missed-queue.service.ts`:
|
|
|
|
```typescript
|
|
async getMissedQueue(agentName: string, authHeader: string): Promise<{
|
|
pending: any[];
|
|
attempted: any[];
|
|
completed: any[];
|
|
invalid: any[];
|
|
}> {
|
|
const fields = `id name createdAt direction callStatus agentName
|
|
callerNumber { primaryPhoneNumber }
|
|
startedAt endedAt durationSec disposition leadId
|
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
|
|
|
const query = (status: string) => `{ calls(first: 50, filter: {
|
|
agentName: { eq: "${agentName}" },
|
|
callStatus: { eq: MISSED },
|
|
callbackstatus: { eq: ${status} }
|
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
|
|
|
try {
|
|
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
|
|
this.platform.queryWithAuth<any>(query('PENDING_CALLBACK'), undefined, authHeader),
|
|
this.platform.queryWithAuth<any>(query('CALLBACK_ATTEMPTED'), undefined, authHeader),
|
|
this.platform.queryWithAuth<any>(query('CALLBACK_COMPLETED'), undefined, authHeader),
|
|
this.platform.queryWithAuth<any>(query('INVALID'), undefined, authHeader),
|
|
this.platform.queryWithAuth<any>(query('WRONG_NUMBER'), undefined, authHeader),
|
|
]);
|
|
|
|
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
|
|
|
return {
|
|
pending: extract(pending),
|
|
attempted: extract(attempted),
|
|
completed: [...extract(completed), ...extract(wrongNumber)],
|
|
invalid: extract(invalid),
|
|
};
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
|
return { pending: [], attempted: [], completed: [], invalid: [] };
|
|
}
|
|
}
|
|
|
|
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
|
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
|
|
if (!validStatuses.includes(status)) {
|
|
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
|
}
|
|
|
|
const data: Record<string, any> = { callbackstatus: status };
|
|
if (status === 'CALLBACK_ATTEMPTED') {
|
|
data.callbackattemptedat = new Date().toISOString();
|
|
}
|
|
|
|
const dataStr = Object.entries(data)
|
|
.map(([k, v]) => typeof v === 'string' && !validStatuses.includes(v) ? `${k}: "${v}"` : `${k}: ${v}`)
|
|
.join(', ');
|
|
|
|
return this.platform.queryWithAuth<any>(
|
|
`mutation { updateCall(id: "${callId}", data: { ${dataStr} }) { id callbackstatus callbackattemptedat } }`,
|
|
undefined,
|
|
authHeader,
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add endpoints to the controller**
|
|
|
|
Modify `helix-engage-server/src/worklist/worklist.controller.ts`. Add imports and inject `MissedQueueService`:
|
|
|
|
```typescript
|
|
import { Controller, Get, Patch, Post, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
|
|
import { MissedQueueService } from './missed-queue.service';
|
|
```
|
|
|
|
Add `MissedQueueService` to constructor:
|
|
|
|
```typescript
|
|
constructor(
|
|
private readonly worklist: WorklistService,
|
|
private readonly missedQueue: MissedQueueService,
|
|
private readonly platform: PlatformGraphqlService,
|
|
) {}
|
|
```
|
|
|
|
Add these endpoints after the existing `@Get()`:
|
|
|
|
```typescript
|
|
@Get('missed-queue')
|
|
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
|
const agentName = await this.resolveAgentName(authHeader);
|
|
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
|
}
|
|
|
|
@Patch('missed-queue/:id/status')
|
|
async updateMissedCallStatus(
|
|
@Param('id') id: string,
|
|
@Headers('authorization') authHeader: string,
|
|
@Body() body: { status: string },
|
|
) {
|
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
|
if (!body.status) throw new HttpException('status is required', 400);
|
|
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update worklist service to include new fields**
|
|
|
|
Modify `helix-engage-server/src/worklist/worklist.service.ts` — update the `getMissedCalls` method (lines 77-95).
|
|
|
|
Change the query to include new fields and FIFO ordering:
|
|
|
|
```typescript
|
|
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
|
try {
|
|
const data = await this.platform.queryWithAuth<any>(
|
|
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
|
id name createdAt
|
|
direction callStatus agentName
|
|
callerNumber { primaryPhoneNumber }
|
|
startedAt endedAt durationSec
|
|
disposition leadId
|
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
|
} } } }`,
|
|
undefined,
|
|
authHeader,
|
|
);
|
|
return data.calls.edges.map((e: any) => e.node);
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
|
return [];
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note**: Sort changed from `DescNullsLast` to `AscNullsLast` for FIFO ordering. Added `callbackstatus` filter to only show active missed calls in the worklist (not completed/invalid).
|
|
|
|
- [ ] **Step 4: Verify sidecar compiles**
|
|
|
|
Run: `cd helix-engage-server && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add helix-engage-server/src/worklist/missed-queue.service.ts helix-engage-server/src/worklist/worklist.controller.ts helix-engage-server/src/worklist/worklist.service.ts
|
|
git commit -m "feat: missed queue endpoints and worklist FIFO ordering with callback fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Frontend — Worklist Types + Hook Update
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage/src/hooks/use-worklist.ts`
|
|
|
|
- [ ] **Step 1: Update MissedCall type and transform**
|
|
|
|
In `helix-engage/src/hooks/use-worklist.ts`, update the `MissedCall` type (lines 5-18) to add the new fields:
|
|
|
|
```typescript
|
|
type MissedCall = {
|
|
id: string;
|
|
createdAt: string;
|
|
callDirection: string | null;
|
|
callStatus: string | null;
|
|
callerNumber: { number: string; callingCode: string }[] | null;
|
|
agentName: string | null;
|
|
startedAt: string | null;
|
|
endedAt: string | null;
|
|
durationSeconds: number | null;
|
|
disposition: string | null;
|
|
callNotes: string | null;
|
|
leadId: string | null;
|
|
// New callback tracking fields
|
|
callbackstatus: string | null;
|
|
callsourcenumber: string | null;
|
|
missedcallcount: number | null;
|
|
callbackattemptedat: string | null;
|
|
};
|
|
```
|
|
|
|
The transform in `missedCalls` mapping (lines 103-110) already spreads all fields — the new lowercase fields will pass through automatically since they match the GraphQL names.
|
|
|
|
- [ ] **Step 2: Verify frontend compiles**
|
|
|
|
Run: `cd helix-engage && npm run build`
|
|
Expected: Clean compilation (may have warnings, no errors)
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add helix-engage/src/hooks/use-worklist.ts
|
|
git commit -m "feat: add callback tracking fields to MissedCall type"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Frontend — Missed Call Status Sub-Tabs
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage/src/components/call-desk/worklist-panel.tsx`
|
|
|
|
This is the largest frontend change. The worklist panel currently has tabs: All | Missed | Callbacks | Follow-ups. We add status sub-tabs under the Missed tab.
|
|
|
|
- [ ] **Step 1: Add sub-tab state and filtering logic**
|
|
|
|
At the top of the `WorklistPanel` component, add a sub-tab state:
|
|
|
|
```typescript
|
|
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
|
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
|
```
|
|
|
|
Add a filtering function that splits missed calls by `callbackstatus`:
|
|
|
|
```typescript
|
|
const missedByStatus = useMemo(() => {
|
|
const pending = missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
|
|
const attempted = missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
|
|
const completed = missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
|
|
const invalid = missedCalls.filter(c => c.callbackstatus === 'INVALID');
|
|
return { pending, attempted, completed, invalid };
|
|
}, [missedCalls]);
|
|
```
|
|
|
|
- [ ] **Step 2: Render sub-tabs when Missed tab is active**
|
|
|
|
Inside the Missed tab content section, add sub-tab buttons before the table:
|
|
|
|
```tsx
|
|
{activeTab === 'missed' && (
|
|
<div className="flex gap-1 px-3 pb-2">
|
|
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
|
<button
|
|
key={sub}
|
|
onClick={() => setMissedSubTab(sub)}
|
|
className={cx(
|
|
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
|
|
missedSubTab === sub
|
|
? 'bg-brand-50 text-brand-700 border border-brand-200'
|
|
: 'text-tertiary hover:text-secondary hover:bg-secondary',
|
|
)}
|
|
>
|
|
{sub}
|
|
{sub === 'pending' && missedByStatus.pending.length > 0 && (
|
|
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
|
|
{missedByStatus.pending.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Use filtered missed calls in the table rows**
|
|
|
|
Where the component currently maps `missedCalls` to table rows for the Missed tab, replace it to use `missedByStatus[missedSubTab]` instead. The active sub-tab determines which missed calls to display.
|
|
|
|
Also add the `missedcallcount` badge and `callsourcenumber` to the row rendering:
|
|
|
|
```tsx
|
|
{mc.missedcallcount && mc.missedcallcount > 1 && (
|
|
<Badge size="sm" color="warning" type="pill-color">
|
|
{mc.missedcallcount}x
|
|
</Badge>
|
|
)}
|
|
{mc.callsourcenumber && (
|
|
<span className="text-xs text-tertiary">{mc.callsourcenumber}</span>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify frontend compiles**
|
|
|
|
Run: `cd helix-engage && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add helix-engage/src/components/call-desk/worklist-panel.tsx
|
|
git commit -m "feat: add status sub-tabs to missed call worklist panel"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend — Pass missedCallId Through Disposition Flow
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage/src/pages/call-desk.tsx`
|
|
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
|
|
|
|
- [ ] **Step 1: Track the active missed call ID in call-desk page**
|
|
|
|
Read `src/pages/call-desk.tsx` to understand how it passes props to `ActiveCallCard`. Add state to track when the current call is a missed call callback:
|
|
|
|
```typescript
|
|
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
|
```
|
|
|
|
When the agent clicks call-back on a missed call from the worklist, set this ID. Pass it to `ActiveCallCard`:
|
|
|
|
```tsx
|
|
<ActiveCallCard lead={matchedLead} callerPhone={callerPhone} missedCallId={activeMissedCallId} />
|
|
```
|
|
|
|
The worklist panel's click-to-call handler should:
|
|
1. Call `PATCH /api/worklist/missed-queue/:id/status` with `{ status: 'CALLBACK_ATTEMPTED' }`
|
|
2. Set `activeMissedCallId` to the missed call's ID
|
|
3. Initiate the outbound call via SIP
|
|
|
|
- [ ] **Step 2: Update ActiveCallCard to pass missedCallId in disposition**
|
|
|
|
Modify `helix-engage/src/components/call-desk/active-call-card.tsx`:
|
|
|
|
Add `missedCallId` to the props interface:
|
|
|
|
```typescript
|
|
interface ActiveCallCardProps {
|
|
lead: Lead | null;
|
|
callerPhone: string;
|
|
missedCallId?: string | null;
|
|
}
|
|
```
|
|
|
|
Update the destructured props:
|
|
|
|
```typescript
|
|
export const ActiveCallCard = ({ lead, callerPhone, missedCallId }: ActiveCallCardProps) => {
|
|
```
|
|
|
|
In `handleDisposition` (line 62-72), add `missedCallId` to the dispose payload:
|
|
|
|
```typescript
|
|
apiClient.post('/api/ozonetel/dispose', {
|
|
ucid: callUcid,
|
|
disposition,
|
|
callerPhone,
|
|
direction: callDirectionRef.current,
|
|
durationSec: callDuration,
|
|
leadId: lead?.id ?? null,
|
|
notes,
|
|
missedCallId: missedCallId ?? undefined,
|
|
}).catch((err) => console.warn('Disposition failed:', err));
|
|
```
|
|
|
|
- [ ] **Step 3: Clear activeMissedCallId after disposition completes**
|
|
|
|
In `call-desk.tsx`, when the call ends and disposition is done (when `ActiveCallCard` signals completion), reset:
|
|
|
|
```typescript
|
|
setActiveMissedCallId(null);
|
|
```
|
|
|
|
- [ ] **Step 4: Verify frontend compiles**
|
|
|
|
Run: `cd helix-engage && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add helix-engage/src/pages/call-desk.tsx helix-engage/src/components/call-desk/active-call-card.tsx
|
|
git commit -m "feat: pass missedCallId through disposition flow for callback status tracking"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Login Page Redesign
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage/src/pages/login.tsx`
|
|
|
|
- [ ] **Step 1: Rewrite login page layout**
|
|
|
|
Replace the entire JSX return in `login.tsx` (from line ~89 onward). Keep ALL the logic (handleSubmit, state, useEffect for remember-me, error handling) — only change the layout:
|
|
|
|
```tsx
|
|
return (
|
|
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
|
|
{/* Login Card */}
|
|
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
|
{/* Logo */}
|
|
<div className="flex flex-col items-center mb-8">
|
|
<img src="/helix-logo-white.svg" alt="Helix Engage" className="h-10 mb-2 invert" />
|
|
<h1 className="text-xl font-semibold text-primary">Sign in to Helix Engage</h1>
|
|
<p className="text-sm text-tertiary mt-1">Global Hospital</p>
|
|
</div>
|
|
|
|
{/* Google Sign In */}
|
|
<button className="w-full flex items-center justify-center gap-3 px-4 py-2.5 border border-secondary rounded-lg hover:bg-secondary transition duration-100 ease-linear mb-4">
|
|
<svg className="size-5" viewBox="0 0 24 24">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
</svg>
|
|
<span className="text-sm font-medium text-secondary">Sign in with Google</span>
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3 my-4">
|
|
<div className="flex-1 h-px bg-tertiary" />
|
|
<span className="text-xs font-medium text-quaternary uppercase tracking-wider">or continue with</span>
|
|
<div className="flex-1 h-px bg-tertiary" />
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="p-3 bg-error-secondary rounded-lg text-sm text-error-primary">{error}</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary mb-1.5">Email</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
placeholder="you@company.com"
|
|
className="w-full px-3 py-2 border border-primary rounded-lg text-sm text-primary placeholder:text-placeholder focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary mb-1.5">Password</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
className="w-full px-3 py-2 border border-primary rounded-lg text-sm text-primary placeholder:text-placeholder focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand pr-10"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-quaternary hover:text-secondary"
|
|
>
|
|
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={rememberMe}
|
|
onChange={e => setRememberMe(e.target.checked)}
|
|
className="size-4 rounded border-primary text-brand-600 focus:ring-brand-500"
|
|
/>
|
|
<span className="text-sm text-secondary">Remember me</span>
|
|
</label>
|
|
<a href="#" className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover">
|
|
Forgot password?
|
|
</a>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-2.5 bg-brand-solid hover:bg-brand-solid_hover text-white font-medium rounded-lg text-sm transition duration-100 ease-linear disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Signing in...' : 'Sign in'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<p className="mt-6 text-xs text-primary_on-brand opacity-60">Powered by FortyTwo</p>
|
|
</div>
|
|
);
|
|
```
|
|
|
|
**Notes**:
|
|
- Check which imports are used — remove unused imports from the old layout (feature card icons, etc.). Keep `FontAwesomeIcon`, `faEye`, `faEyeSlash`.
|
|
- Add `showPassword` state if not already present: `const [showPassword, setShowPassword] = useState(false);`
|
|
- The existing login page uses `Input`, `Button`, `Checkbox` components from the Untitled UI library. **Prefer reusing these components** over raw HTML elements for consistency. Read the existing login page to see which components are already imported and adapt the template above to use them (e.g., `<Input label="Email" ...>` instead of `<input ...>`, `<Button color="primary">Sign in</Button>` instead of `<button ...>`).
|
|
- The existing page uses `Button` with `color="secondary"` for Google sign-in — reuse that pattern.
|
|
|
|
- [ ] **Step 2: Verify the logo path exists**
|
|
|
|
Check if `/helix-logo-white.svg` exists in `helix-engage/public/`. If the logo uses a different path or component, adjust the `<img>` src accordingly. Read the old login page to find the actual logo import.
|
|
|
|
- [ ] **Step 3: Verify frontend compiles**
|
|
|
|
Run: `cd helix-engage && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add helix-engage/src/pages/login.tsx
|
|
git commit -m "feat: redesign login page — centered white card on blue background"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Button Width Fix
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage/src/components/call-desk/disposition-form.tsx`
|
|
- Possibly modify: other call desk forms (verify first)
|
|
|
|
- [ ] **Step 1: Fix disposition form button**
|
|
|
|
Read `disposition-form.tsx` and find the submit button (around line 97-109). If it uses `w-full` or the parent container forces full width, change the button container to:
|
|
|
|
```tsx
|
|
<div className="flex justify-end pt-3">
|
|
<Button ...props (remove w-full if present)>
|
|
Save Disposition
|
|
</Button>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Check other forms**
|
|
|
|
Read `appointment-form.tsx`, `enquiry-form.tsx`, `transfer-dialog.tsx` — check if their buttons also use `w-full`. Only fix the ones that do. The spec review found that these may already be content-width.
|
|
|
|
- [ ] **Step 3: Verify frontend compiles**
|
|
|
|
Run: `cd helix-engage && npm run build`
|
|
Expected: Clean compilation
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add helix-engage/src/components/call-desk/disposition-form.tsx
|
|
git commit -m "fix: use content-width buttons in call desk forms"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Deploy + Verify
|
|
|
|
**Files:** None (deploy only)
|
|
|
|
- [ ] **Step 1: Build and deploy sidecar**
|
|
|
|
```bash
|
|
cd helix-engage-server && npm run build && \
|
|
tar czf /tmp/sidecar-src.tar.gz --exclude=node_modules --exclude=.git --exclude=dist . && \
|
|
tar czf /tmp/sidecar-dist.tar.gz -C dist . && \
|
|
scp -i ~/Downloads/fortytwoai_hostinger /tmp/sidecar-src.tar.gz /tmp/sidecar-dist.tar.gz root@148.230.67.184:/tmp/ && \
|
|
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker cp /tmp/sidecar-src.tar.gz fortytwo-staging-sidecar-1:/tmp/ && docker cp /tmp/sidecar-dist.tar.gz fortytwo-staging-sidecar-1:/tmp/ && docker exec fortytwo-staging-sidecar-1 sh -c 'cd /app && tar xzf /tmp/sidecar-src.tar.gz && tar xzf /tmp/sidecar-dist.tar.gz -C dist' && cd /opt/fortytwo && docker compose restart sidecar"
|
|
```
|
|
|
|
- [ ] **Step 2: Build and deploy frontend**
|
|
|
|
```bash
|
|
cd helix-engage && \
|
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
|
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
|
|
VITE_SIP_PASSWORD=523590 \
|
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
|
|
npm run build && \
|
|
tar czf /tmp/frontend-deploy.tar.gz -C dist . && \
|
|
scp -i ~/Downloads/fortytwoai_hostinger /tmp/frontend-deploy.tar.gz root@148.230.67.184:/tmp/ && \
|
|
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "cd /opt/fortytwo/helix-engage-frontend && rm -rf * && tar xzf /tmp/frontend-deploy.tar.gz"
|
|
```
|
|
|
|
- [ ] **Step 3: Verify sidecar ingestion is running**
|
|
|
|
```bash
|
|
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 20 2>&1 | grep -i 'missed\|ingest\|queue'"
|
|
```
|
|
|
|
Expected: Log lines showing ingestion polling started
|
|
|
|
- [ ] **Step 4: Verify login page**
|
|
|
|
Open the deployed frontend URL. Login page should show centered white card on blue background.
|
|
|
|
- [ ] **Step 5: Verify missed call sub-tabs**
|
|
|
|
Log in as a CC agent, navigate to Call Desk. The Missed tab should show sub-tabs: Pending | Attempted | Completed | Invalid.
|
|
|
|
- [ ] **Step 6: Commit any deployment fixes**
|
|
|
|
If any issues found during verification, fix and recommit.
|