diff --git a/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts b/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts
index 9dd8abb..f5e6960 100644
--- a/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts
+++ b/src/app/app-modules/call/beneficiary/beneficiary-registration.component.ts
@@ -981,6 +981,24 @@ function validDob(control: AbstractControl): ValidationErrors | null {
+
+ @if (registerError(); as msg) {
+
+ {{ msg }}
+
+
+ }
}
@@ -1070,6 +1088,12 @@ export class BeneficiaryRegistrationComponent implements OnInit {
private searchRequestId = 0;
readonly registerLoading = signal(false);
+ /**
+ * Persistent register-failure banner. The toast alone disappears before the
+ * agent can act on it, leaving a silent dead-end; this stays visible until
+ * the next submit attempt (or is dismissed).
+ */
+ readonly registerError = signal(null);
// --- Register-form view state -------------------------------------------
readonly page = signal<1 | 2>(1);
@@ -1584,6 +1608,7 @@ export class BeneficiaryRegistrationComponent implements OnInit {
}
doRegister(): void {
+ this.registerError.set(null);
// Hard guard: a disabled form reports as valid, so this must run before the
// invalid-check below to stop a submit with an empty phoneNo.
if (this.cliMissing() || !this.callStore.cli()) {
@@ -1683,6 +1708,7 @@ export class BeneficiaryRegistrationComponent implements OnInit {
},
error: (err: BeneficiaryError) => {
this.registerLoading.set(false);
+ this.registerError.set(this.i18n.instant('registration.register.error'));
toast.error(
err?.errorMessage || this.i18n.instant('registration.toast.error'),
);
diff --git a/src/app/app-modules/call/hao/hao.service.ts b/src/app/app-modules/call/hao/hao.service.ts
index 0c863bc..9f1ea44 100644
--- a/src/app/app-modules/call/hao/hao.service.ts
+++ b/src/app/app-modules/call/hao/hao.service.ts
@@ -159,7 +159,21 @@ export class HaoService {
providerServiceMapID: serviceID,
...(isInbound ? { isInbound: true } : { isOutbound: true }),
})
- .pipe(map((res) => res.data ?? []));
+ .pipe(
+ // Call types are mandatory for closure: an absent payload (null /
+ // undefined) is a legitimate empty catalogue, but any other non-array
+ // shape is malformed and must hit the caller's error path (visible to
+ // the agent), not render an empty dropdown that strands the call.
+ map((res) => {
+ if (res.data == null) return [];
+ if (!Array.isArray(res.data)) {
+ throw new Error(
+ 'getCallTypes: expected array, got ' + typeof res.data,
+ );
+ }
+ return res.data;
+ }),
+ );
}
/** Record the call disposition and close the call. */
@@ -171,14 +185,20 @@ export class HaoService {
// --- Transfer (CTI) -----------------------------------------------------
- /** Campaigns the active call may be transferred to. */
+ /**
+ * Campaigns the active call may be transferred to.
+ *
+ * UAT returns 200 with a non-array `data` payload for this endpoint, which
+ * the `@for` over the campaign list cannot iterate — anything that is not an
+ * array is treated as "no campaigns".
+ */
getTransferCampaigns(agentID: number): Observable {
return this.http
.post>(
this.baseCommon + PATHS.transferCampaigns,
{ agent_id: agentID },
)
- .pipe(map((res) => res.data ?? []));
+ .pipe(map((res) => (Array.isArray(res.data) ? res.data : [])));
}
/** Skills available within a chosen transfer campaign (keyed by name). */
@@ -187,7 +207,7 @@ export class HaoService {
.post>(this.baseCommon + PATHS.campaignSkills, {
campaign_name: campaignName,
})
- .pipe(map((res) => res.data ?? []));
+ .pipe(map((res) => (Array.isArray(res.data) ? res.data : [])));
}
/**
diff --git a/src/app/app-modules/call/hao/steps/closure-step.component.ts b/src/app/app-modules/call/hao/steps/closure-step.component.ts
index 22c9005..3e6a0a9 100644
--- a/src/app/app-modules/call/hao/steps/closure-step.component.ts
+++ b/src/app/app-modules/call/hao/steps/closure-step.component.ts
@@ -601,7 +601,10 @@ export class ClosureStepComponent {
return;
}
this.haoService.getTransferCampaigns(agentID).subscribe({
- next: (campaigns) => this.campaigns.set(campaigns),
+ // Belt-and-braces: the template's @for iterates this signal, so a
+ // non-array value (misbehaving backend, stale mock) must never land.
+ next: (campaigns) =>
+ this.campaigns.set(Array.isArray(campaigns) ? campaigns : []),
error: () => this.campaigns.set([]),
});
}
diff --git a/src/app/app-modules/call/inbound-cti.service.ts b/src/app/app-modules/call/inbound-cti.service.ts
new file mode 100644
index 0000000..9fc7adc
--- /dev/null
+++ b/src/app/app-modules/call/inbound-cti.service.ts
@@ -0,0 +1,133 @@
+/*
+ * AMRIT – Accessible Medical Records via Integrated Technologies
+ * Integrated EHR (Electronic Health Records) Solution
+ *
+ * Copyright (C) "Piramal Swasthya Management and Research Institute"
+ *
+ * This file is part of AMRIT.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see https://www.gnu.org/licenses/.
+ */
+
+import { DestroyRef, Injectable, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { environment } from '@env/environment';
+
+import { AuthStore } from '../core/auth/auth.store';
+
+import { CallStore } from './call.store';
+import { parseInboundCtiMessage } from './cti-message';
+
+/** Feature code of the supervising role, which has no personal agent line. */
+const SUPERVISOR_FEATURE_CODE = 'Supervisor';
+
+/**
+ * Extract the origin from a configured base URL. Returns a token that can never
+ * equal a real `MessageEvent.origin` when the URL is empty/malformed, so an
+ * unconfigured telephony server trusts nothing rather than everything.
+ */
+function safeOrigin(url: string): string {
+ try {
+ return new URL(url).origin;
+ } catch {
+ return 'invalid:no-telephony-origin';
+ }
+}
+
+/**
+ * App-scoped listener for inbound CTI events from the CZentrix soft-phone.
+ *
+ * The CZentrix CTI iframe announces inbound calls to the host window via
+ * postMessage ("Accept|||INBOUND"). The iframe lives in the
+ * root-level CTI panel and persists across every route, so the listener must
+ * be app-scoped too — an inbound call must seed the {@link CallStore} and route
+ * into the guarded on-call workspace no matter which screen the agent is on.
+ *
+ * Instantiated once by the root `App` component; the listener stays registered
+ * for the lifetime of the application.
+ */
+@Injectable({ providedIn: 'root' })
+export class InboundCtiService {
+ private readonly authStore = inject(AuthStore);
+ private readonly callStore = inject(CallStore);
+ private readonly router = inject(Router);
+
+ /** Origin of the CZentrix telephony server, the only trusted CTI sender. */
+ private readonly telephonyOrigin = safeOrigin(environment.telephoneServer);
+
+ constructor() {
+ const onMessage = (event: MessageEvent): void => {
+ if (!this.isTrustedCtiOrigin(event.origin) || !this.isCtiEligible()) {
+ return;
+ }
+ this.handleCtiMessage(event.data);
+ };
+ window.addEventListener('message', onMessage);
+ inject(DestroyRef).onDestroy(() =>
+ window.removeEventListener('message', onMessage),
+ );
+ }
+
+ /**
+ * Only accept CTI events from the CZentrix telephony origin — never from an
+ * arbitrary page/iframe that could forge an "inbound call". The dev simulator
+ * posts from this app's own origin, which is trusted in non-production builds.
+ */
+ private isTrustedCtiOrigin(origin: string): boolean {
+ if (origin === this.telephonyOrigin) {
+ return true;
+ }
+ return !environment.production && origin === window.location.origin;
+ }
+
+ /**
+ * Whether the current session may take inbound CTI calls — mirrors the
+ * CtiPanelComponent `showCzentrix` gate: an authenticated user with a
+ * telephony agent id and a selected non-supervisor role. The listener stays
+ * registered for the app's lifetime, so without this gate a message arriving
+ * on the login screen or in a supervisor session would seed call state and
+ * navigate into the on-call workspace.
+ */
+ private isCtiEligible(): boolean {
+ if (
+ !this.authStore.isAuthenticated() ||
+ (this.authStore.user()?.agentID ?? null) === null
+ ) {
+ return false;
+ }
+ const featureCode = this.authStore.currentRole()?.featureCode ?? null;
+ return featureCode !== null && featureCode !== SUPERVISOR_FEATURE_CODE;
+ }
+
+ /** Parse a CTI payload; on a fresh inbound call, seed state and navigate. */
+ private handleCtiMessage(data: unknown): void {
+ const inbound = parseInboundCtiMessage(data);
+ if (!inbound) {
+ return;
+ }
+ // De-dupe: the iframe may re-post the same event for one connected call.
+ if (
+ this.callStore.onCall() &&
+ this.callStore.sessionId() === inbound.sessionId
+ ) {
+ return;
+ }
+
+ this.callStore.startCall({
+ cli: inbound.cli,
+ sessionId: inbound.sessionId,
+ });
+ void this.router.navigate(['/innerpage']);
+ }
+}
diff --git a/src/app/app-modules/core/i18n/locales/as.ts b/src/app/app-modules/core/i18n/locales/as.ts
index b5b709e..df61a8e 100644
--- a/src/app/app-modules/core/i18n/locales/as.ts
+++ b/src/app/app-modules/core/i18n/locales/as.ts
@@ -167,6 +167,9 @@ export const as: Record = {
'registration.toast.selected': 'এই কলৰ বাবে হিতাধিকাৰী বাছনি কৰা হ’ল।',
'registration.toast.registered': 'হিতাধিকাৰী পঞ্জীয়ন আৰু বাছনি কৰা হ’ল।',
'registration.toast.error': 'কিবা ভুল হ’ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক।',
+ 'registration.register.error':
+ "পঞ্জীয়ন বিফল হ'ল। অনুগ্ৰহ কৰি পুনৰ চেষ্টা কৰক বা সহায়ৰ সৈতে যোগাযোগ কৰক।",
+ 'registration.register.dismiss': 'বন্ধ কৰক',
'registration.toast.noCli': 'এই কলৰ বাবে কোনো কলাৰ নম্বৰ উপলব্ধ নহয়।',
'registration.toast.masterError': 'কিছুমান ফৰ্ম বিকল্প ল’ড কৰিব পৰা নগ’ল।',
'registration.notice.noCli':
diff --git a/src/app/app-modules/core/i18n/locales/en.ts b/src/app/app-modules/core/i18n/locales/en.ts
index f3da285..e3ab250 100644
--- a/src/app/app-modules/core/i18n/locales/en.ts
+++ b/src/app/app-modules/core/i18n/locales/en.ts
@@ -165,6 +165,9 @@ export const en = {
'registration.toast.selected': 'Beneficiary selected for this call.',
'registration.toast.registered': 'Beneficiary registered and selected.',
'registration.toast.error': 'Something went wrong. Please try again.',
+ 'registration.register.error':
+ 'Registration failed. Please try again or contact support.',
+ 'registration.register.dismiss': 'Close',
'registration.toast.noCli': 'No caller number is available for this call.',
'registration.toast.masterError': 'Could not load some form options.',
'registration.notice.noCli':
diff --git a/src/app/app-modules/core/i18n/locales/hi.ts b/src/app/app-modules/core/i18n/locales/hi.ts
index b9d73e8..43eeb1f 100644
--- a/src/app/app-modules/core/i18n/locales/hi.ts
+++ b/src/app/app-modules/core/i18n/locales/hi.ts
@@ -167,6 +167,9 @@ export const hi: Record = {
'registration.toast.selected': 'इस कॉल के लिए लाभार्थी चुना गया।',
'registration.toast.registered': 'लाभार्थी पंजीकृत और चयनित किया गया।',
'registration.toast.error': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।',
+ 'registration.register.error':
+ 'पंजीकरण विफल हुआ। कृपया पुनः प्रयास करें या सहायता से संपर्क करें।',
+ 'registration.register.dismiss': 'बंद करें',
'registration.toast.noCli': 'इस कॉल के लिए कोई कॉलर नंबर उपलब्ध नहीं है।',
'registration.toast.masterError': 'कुछ फ़ॉर्म विकल्प लोड नहीं हो सके।',
'registration.notice.noCli':
diff --git a/src/app/app-modules/dashboard/components/dashboard-footer.component.ts b/src/app/app-modules/dashboard/components/dashboard-footer.component.ts
index f996e93..bf907e7 100644
--- a/src/app/app-modules/dashboard/components/dashboard-footer.component.ts
+++ b/src/app/app-modules/dashboard/components/dashboard-footer.component.ts
@@ -20,44 +20,30 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-import {
- ChangeDetectionStrategy,
- Component,
- computed,
- inject,
- input,
- signal,
-} from '@angular/core';
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMail } from '@ng-icons/lucide';
-import { ZardButtonComponent } from '@common-ui/ui/button';
-
import { AppFooterComponent } from '@/shared/components/layout/app-footer.component';
import { AuthStore } from '../../core/auth/auth.store';
import { I18nService } from '../../core/i18n/i18n.service';
import { TranslatePipe } from '../../core/i18n/translate.pipe';
-import { ConfigService } from '../../core/services/config.service';
-/** Static brand constants shown in the footer (not translatable). */
-const CZENTRIX_LABEL = 'CZentrix';
-const CTI_HANDLER_PATH = 'bar/cti_handler.php';
const FEEDBACK_ROUTE = '/feedback';
/**
* Dashboard footer: the shared copyright / version chrome plus the post-logout
- * feedback link and — for call-handling roles — the CZentrix button that
- * toggles the CTI (telephony soft-phone) bar.
+ * feedback link. The CZentrix CTI panel is no longer footer-owned — it lives in
+ * the app-root `CtiPanelComponent` so it persists across all routes.
*/
@Component({
selector: 'app-dashboard-footer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [NgIcon, ZardButtonComponent, TranslatePipe, AppFooterComponent],
+ imports: [NgIcon, TranslatePipe, AppFooterComponent],
viewProviders: [provideIcons({ lucideMail })],
template: `
@@ -69,56 +55,16 @@ const FEEDBACK_ROUTE = '/feedback';
{{ 'dashboard.footer.feedback' | translate: lang() }}
-
- @if (showCzentrix()) {
-
- }
-
- @if (showCzentrix() && ctiOpen() && ctiUrl(); as src) {
-
- }
`,
})
export class DashboardFooterComponent {
private readonly i18n = inject(I18nService);
- private readonly config = inject(ConfigService);
private readonly router = inject(Router);
- private readonly sanitizer = inject(DomSanitizer);
private readonly authStore = inject(AuthStore);
readonly lang = this.i18n.language;
- /** Whether to show the CZentrix CTI toggle (call-handling roles only). */
- readonly showCzentrix = input(false);
- /** Telephony agent id used to address the CTI handler. */
- readonly agentId = input(null);
-
- readonly czentrixLabel = CZENTRIX_LABEL;
-
- private readonly _ctiOpen = signal(false);
- readonly ctiOpen = this._ctiOpen.asReadonly();
-
- /** Sanitized CTI bar URL, or null when no agent id is available. */
- readonly ctiUrl = computed(() => {
- const id = this.agentId();
- if (id === null) {
- return null;
- }
- const url = `${this.config.getTelephonyServerURL()}${CTI_HANDLER_PATH}?e=${id}`;
- return this.sanitizer.bypassSecurityTrustResourceUrl(url);
- });
-
- toggleCti(): void {
- this._ctiOpen.update((open) => !open);
- }
-
goToFeedback(): void {
// The feedback page is anonymous: clear the session before navigating.
this.authStore.clear();
diff --git a/src/app/app-modules/dashboard/dashboard.component.ts b/src/app/app-modules/dashboard/dashboard.component.ts
index e78d053..e0a23d9 100644
--- a/src/app/app-modules/dashboard/dashboard.component.ts
+++ b/src/app/app-modules/dashboard/dashboard.component.ts
@@ -23,21 +23,15 @@
import {
ChangeDetectionStrategy,
Component,
- DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { Router } from '@angular/router';
import { environment } from '@env/environment';
import { catchError, filter, of, switchMap, timer } from 'rxjs';
-import { ZardButtonComponent } from '@common-ui/ui/button';
-
-import { CallStore } from '../call/call.store';
-import { parseInboundCtiMessage } from '../call/cti-message';
import { AuthStore } from '../core/auth/auth.store';
import { AgentState, AgentStatusService } from './agent-status.service';
import { AgentIdComponent } from './components/agent-id.component';
@@ -75,19 +69,6 @@ const AGENT_STATUS_POLL_MS = 15_000;
/** States whose type suffix the legacy dashboard suppressed. */
const ON_CALL_STATES: readonly string[] = ['INCALL', 'CLOSURE'];
-/**
- * Extract the origin from a configured base URL. Returns a token that can never
- * equal a real `MessageEvent.origin` when the URL is empty/malformed, so an
- * unconfigured telephony server trusts nothing rather than everything.
- */
-function safeOrigin(url: string): string {
- try {
- return new URL(url).origin;
- } catch {
- return 'invalid:no-telephony-origin';
- }
-}
-
/**
* Dashboard shell for the 104 agent desktop: navigation header, left rail, the
* agent line / campaign selector, call statistics, the alerts, reports,
@@ -114,7 +95,6 @@ function safeOrigin(url: string): string {
ReportsPanelComponent,
ActivityPanelComponent,
RatingPanelComponent,
- ZardButtonComponent,
],
template: `
@@ -154,15 +134,12 @@ function safeOrigin(url: string): string {