Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,38 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideGraduationCap } from '@ng-icons/lucide';
import { lucideGraduationCap, lucidePhoneOutgoing } from '@ng-icons/lucide';
import { switchMap } from 'rxjs/operators';

import { cardImports } from '@common-ui/ui/card';

import { CtiService } from '@/app-modules/core/services/cti.service';
import { NotificationApiService } from '@/app-modules/core/services/notification-api.service';
import { NotificationService } from '@/app-modules/core/services/notification.service';
import { CallStore } from '@/app-modules/core/state/call.store';
import { SessionStore } from '@/app-modules/core/state/session.store';

const KM_TYPE = 'KM';

/**
* "Activity for this week" panel. Faithful to the old `activity-this-week`: shows the
* count of Training Resources (KM docs) for the current role/service. The training-doc
* dialog and the outbound-worklist link are deferred to later phases.
* "Activity for this week" panel (old `activity-this-week`): Training Resources count and
* the CO-on-OUTBOUND "Outbound Worklist" link. The training-doc dialog is a later phase.
*/
@Component({
selector: 'app-activity-panel',
imports: [...cardImports, NgIcon],
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [provideIcons({ lucideGraduationCap })],
viewProviders: [provideIcons({ lucideGraduationCap, lucidePhoneOutgoing })],
template: `
<z-card class="h-full shadow-sm transition-shadow hover:shadow-md">
<z-card-header class="border-b pb-3">
Expand All @@ -51,6 +61,16 @@ const KM_TYPE = 'KM';
</z-card-title>
</z-card-header>
<z-card-content class="pt-4">
@if (showOutboundLink()) {
<button
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-primary hover:bg-accent"
(click)="openOutboundWorklist()"
>
<ng-icon name="lucidePhoneOutgoing" class="text-base" aria-hidden="true" />
Outbound Worklist
</button>
}
@let c = trainingCount();
<div class="flex items-center justify-between rounded-md px-2 py-2 hover:bg-accent">
<span class="text-sm">Training Resources</span>
Expand All @@ -71,8 +91,34 @@ const KM_TYPE = 'KM';
export class ActivityPanelComponent implements OnInit {
private readonly notificationApi = inject(NotificationApiService);
private readonly sessionStore = inject(SessionStore);
private readonly callStore = inject(CallStore);
private readonly cti = inject(CtiService);
private readonly notify = inject(NotificationService);
private readonly router = inject(Router);

protected readonly trainingCount = signal(0);
protected readonly showOutboundLink = computed(
() =>
this.sessionStore.currentRole() === 'CO' &&
this.callStore.currentCampaign() === 'OUTBOUND',
);

/** Old `agentLoginStatus()` β€” the "already in MANUAL mode" error still navigates. */
protected openOutboundWorklist(): void {
this.cti.switchToOutbound().subscribe({
next: () => {
this.callStore.setCurrentCampaign('OUTBOUND');
this.router.navigate(['/MultiRoleScreenComponent/OutboundCallWorklistsComponent']);
},
error: (err: { errorMessage?: string }) => {
if ((err?.errorMessage ?? '').includes('already in MANUAL mode')) {
this.router.navigate(['/MultiRoleScreenComponent/OutboundCallWorklistsComponent']);
} else {
this.notify.alert(err?.errorMessage ?? 'Failed to switch to outbound', 'error');
}
},
});
}

ngOnInit(): void {
const serviceId = this.sessionStore.currentServiceId();
Expand Down
70 changes: 1 addition & 69 deletions src/app/app-modules/auth/dashboard/dashboard.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core';
import { Router } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';

import { NotificationService } from '@/app-modules/core/services/notification.service';
import { CALL_SCREEN_ROUTE, CallStore } from '@/app-modules/core/state/call.store';
import { SessionStore } from '@/app-modules/core/state/session.store';

import { ActivityPanelComponent } from './components/activity-panel.component';
Expand Down Expand Up @@ -89,71 +86,6 @@ import { RatingPanelComponent } from './components/rating-panel.component';
})
export class DashboardComponent {
private readonly sessionStore = inject(SessionStore);
private readonly callStore = inject(CallStore);
private readonly router = inject(Router);
private readonly notify = inject(NotificationService);
private readonly destroyRef = inject(DestroyRef);

protected readonly isSupervisor = computed(() => this.sessionStore.currentRole() === 'Supervisor');

constructor() {
// The CZentrix iframe announces calls via window.postMessage β€” old dashboard `listener`.
const listener = (event: Event) => this.onCtiMessage(event);
window.addEventListener('message', listener, false);
this.destroyRef.onDestroy(() => window.removeEventListener('message', listener, false));
}

/**
* Old `listener(event)`: parse the pipe-delimited CTI event
* `"{Action}|{phone}|{sessionId}|{INBOUND|OUTBOUND}"` (from `event.data`, or
* `event.detail.data` for CustomEvents). Handle it when it carries a session id we don't
* already have, or is an explicit Accept.
*/
private onCtiMessage(event: Event): void {
const raw =
(event as MessageEvent).data ?? (event as CustomEvent<{ data?: unknown }>).detail?.data;
if (typeof raw !== 'string') {
// Browsers/devtools post non-CZentrix objects on window too; only pipe strings matter.
return;
}
const parts = raw.split('|');
const sessionId = parts[2];
if (sessionId === undefined || sessionId === 'undefined' || sessionId === null || sessionId === '') {
return;
}
// Single dispatch (review fix): the old app's two independent `if` blocks invoked
// handleEvent twice for an Accept with a new session id β€” harmless only by accident
// (idempotent store writes, same-URL navigation). Same trigger conditions, one call.
const known = this.callStore.sessionId();
const isNewSession = !known || known !== sessionId;
const isAccept = parts[0]?.toLowerCase() === 'accept';
if (isNewSession || isAccept) {
this.handleCtiEvent(parts);
}
}

/** Old `handleEvent()`: validate, persist the call flags, open the call screen. */
private handleCtiEvent(parts: string[]): void {
if (parts.length <= 2) {
return;
}
const mobileNumber = (parts[1] ?? '').replace(/\D/g, '');
const checkNumber = /^\d+$/;
const sessionVar = /^\d{10}\.\d{10}$/;
// Review fix (deviation from the old app, declared on PR #6): the pattern is anchored β€”
// the old `^(INBOUND)|(OUTBOUND)$` accepted e.g. "INBOUNDxyz". Real events carry bare
// tokens (the old innerpage compared `=== 'OUTBOUND'` exactly); verified at the
// live-call milestone together with the deferred origin check.
const checkCallType = /^(INBOUND|OUTBOUND)$/i;

if (checkNumber.test(mobileNumber) && sessionVar.test(parts[2]) && checkCallType.test(parts[3])) {
// Review fix: isOnCall is set only for a VALID call (startCall sets it) β€” the old app
// set it before validating, stranding the agent behind the mid-call guards when a
// malformed event arrived (flag set, no call, no navigation, logout blocked).
this.callStore.startCall(parts[1], parts[2], parts[3]);
this.router.navigate([CALL_SCREEN_ROUTE]);
} else {
this.notify.alert('Invalid call. Please check.', 'error');
}
}
}
17 changes: 16 additions & 1 deletion src/app/app-modules/auth/shell/shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
Expand All @@ -34,6 +41,7 @@ import { ZardDialogService } from '@common-ui/ui/dialog';
import { APP_VERSION } from '@/app-modules/core/app-version';
import { AuthService } from '@/app-modules/core/auth/auth.service';
import { ConfigService } from '@/app-modules/core/services/config.service';
import { CtiCallEventsService } from '@/app-modules/core/services/cti-call-events.service';
import { CtiService } from '@/app-modules/core/services/cti.service';
import { NotificationService } from '@/app-modules/core/services/notification.service';
import {
Expand Down Expand Up @@ -70,6 +78,13 @@ export class ShellComponent {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly auth = inject(AuthService);
private readonly ctiEvents = inject(CtiCallEventsService);
private readonly shellDestroyRef = inject(DestroyRef);

constructor() {
// Shell-wide CZentrix call-event listener (see CtiCallEventsService for why).
this.ctiEvents.attach(this.shellDestroyRef);
}
private readonly cti = inject(CtiService);
private readonly config = inject(ConfigService);
private readonly dialog = inject(ZardDialogService);
Expand Down
72 changes: 63 additions & 9 deletions src/app/app-modules/call/closure/closure.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ZardSelectImports } from '@common-ui/ui/select';

import { CallApiService } from '@/app-modules/core/services/call-api.service';
import { NotificationService } from '@/app-modules/core/services/notification.service';
import { OutboundApiService } from '@/app-modules/core/services/outbound-api.service';
import {
ENCRYPTED_KEYS,
SessionStorageService,
Expand Down Expand Up @@ -73,15 +74,15 @@ import { SessionStore } from '@/app-modules/core/state/session.store';
<form [formGroup]="form" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label class="flex flex-col gap-1.5 text-sm">
<span>Call Type <span class="text-destructive">*</span></span>
<z-select formControlName="callType" zPlaceholder="Select call type" (zValueChange)="onCallTypeChange()">
<z-select formControlName="callType" zPlaceholder="Select call type" (zValueChange)="onCallTypeChange($event)">
@for (g of callGroups(); track g) {
<z-select-item [zValue]="g">{{ g }}</z-select-item>
}
</z-select>
</label>
<label class="flex flex-col gap-1.5 text-sm">
<span>Call Sub-Type <span class="text-destructive">*</span></span>
<z-select formControlName="callSubType" zPlaceholder="Select sub-type" (zValueChange)="onSubTypeChange()">
<z-select formControlName="callSubType" zPlaceholder="Select sub-type" (zValueChange)="onSubTypeChange($event)">
@for (st of subTypes(); track st.callTypeID) {
<z-select-item [zValue]="subTypeValue(st)">{{ st.callType }}</z-select-item>
}
Expand All @@ -91,7 +92,7 @@ import { SessionStore } from '@/app-modules/core/state/session.store';
@if (transferValid()) {
<label class="flex flex-col gap-1.5 text-sm">
<span>Transfer Campaign <span class="text-destructive">*</span></span>
<z-select formControlName="campaignName" zPlaceholder="Select campaign" (zValueChange)="onCampaignChange()">
<z-select formControlName="campaignName" zPlaceholder="Select campaign" (zValueChange)="onCampaignChange($event)">
@for (c of campaigns(); track c) {
<z-select-item [zValue]="c">{{ c }}</z-select-item>
}
Expand Down Expand Up @@ -201,6 +202,7 @@ import { SessionStore } from '@/app-modules/core/state/session.store';
export class ClosureComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly callApi = inject(CallApiService);
private readonly outboundApi = inject(OutboundApiService);
private readonly notify = inject(NotificationService);
private readonly sessionStore = inject(SessionStore);
private readonly callStore = inject(CallStore);
Expand Down Expand Up @@ -232,6 +234,8 @@ export class ClosureComponent implements OnInit {
/** Old `isEverwell` β€” the feedback checkbox is hidden on Everwell calls. */
protected readonly isEverwell =
this.storage.getItem(ENCRYPTED_KEYS.isEverwellCall) === 'yes';
private readonly isGrievance =
this.storage.getItem(ENCRYPTED_KEYS.isGrievanceCall) === 'yes';
protected readonly campaigns = signal<string[]>([]);
protected readonly skills = signal<string[]>([]);
protected readonly languages = signal<{ languageID?: number; languageName?: string }[]>([]);
Expand Down Expand Up @@ -357,8 +361,10 @@ export class ClosureComponent implements OnInit {
return `${st.callTypeID},${st.fitToBlock ?? ''},${st.fitForFollowUp ?? ''}`;
}

protected onCallTypeChange(): void {
const group = this.form.controls.callType.value;
// Handlers take the emitted value: z-select fires zValueChange BEFORE its CVA writes the
// form control, so reading the control here would see the previous selection.
protected onCallTypeChange(value: string | string[]): void {
const group = value as string;
this.form.patchValue({ callSubType: null });
this.subTypes.set([]);
this.showFollowUp.set(false);
Expand Down Expand Up @@ -420,8 +426,8 @@ export class ClosureComponent implements OnInit {
}

/** Old `sliderVisibility` β€” follow-up shows when the sub-type's fitForFollowUp is "true". */
protected onSubTypeChange(): void {
const value = this.form.controls.callSubType.value ?? '';
protected onSubTypeChange(subType: string | string[]): void {
const value = (subType as string) ?? '';
const fitForFollowUp = value.split(',')[2];
this.showFollowUp.set(fitForFollowUp === 'true');
// A hidden checkbox left the old form entirely (`isFollowupRequired == undefined` β†’
Expand All @@ -432,10 +438,10 @@ export class ClosureComponent implements OnInit {
this.syncFollowUpValidators();
}

protected onCampaignChange(): void {
protected onCampaignChange(value: string | string[]): void {
this.skills.set([]);
this.form.patchValue({ campaignSkill: null });
const name = this.form.controls.campaignName.value;
const name = value as string;
if (!name) {
return;
}
Expand Down Expand Up @@ -534,6 +540,54 @@ export class ClosureComponent implements OnInit {
return;
}
this.busy.set(true);
// OUTBOUND completes the worklist item FIRST, then closes (old branch order); the
// bare-HTTP-status error alerts are the old handlers' quirk.
if (campaign === 'OUTBOUND') {
if (!this.isEverwell && !this.isGrievance) {
this.callApi.completeOutboundCall(this.callStore.outBoundCallID(), true).subscribe({
next: () => this.postCloseCall(request, kind, campaign),
error: (err: { status?: number }) => {
this.busy.set(false);
this.notify.alert(String(err?.status ?? 'error'), 'error');
},
});
return;
}
if (this.isGrievance) {
const grievanceData = this.callStore.outboundGrievanceData() ?? {};
this.outboundApi
.completeGrievanceCall({
complaintID: grievanceData['complaintID'],
userID: this.sessionStore.userId(),
isCompleted: true,
beneficiaryRegID: grievanceData['beneficiaryRegID'] ?? grievanceData['beneficiaryRegId'],
callTypeID: request.callTypeID,
benCallID: request.benCallID,
providerServiceMapID: request.providerServiceMapID,
createdBy: this.sessionStore.user()?.userName,
})
.subscribe({
next: () => this.postCloseCall(request, kind, campaign),
error: (err: { status?: number }) => {
this.busy.set(false);
this.notify.alert(String(err?.status ?? 'error'), 'error');
},
});
return;
}
// Everwell without feedback data posted NOTHING in the old app (silent no-op quirk);
// the Phase 8 feedback flow adds the completion branch.
this.busy.set(false);
return;
}
this.postCloseCall(request, kind, campaign);
}

private postCloseCall(
request: CloseCallRequest,
kind: 'continue' | 'close',
campaign: string | null,
): void {
this.callApi.closeCall(request).subscribe({
next: () => {
this.busy.set(false);
Expand Down
Loading