diff --git a/src/app/app-modules/supervisor/allocation/allocate-records.component.ts b/src/app/app-modules/supervisor/allocation/allocate-records.component.ts new file mode 100644 index 0000000..17efc95 --- /dev/null +++ b/src/app/app-modules/supervisor/allocation/allocate-records.component.ts @@ -0,0 +1,359 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + OnInit, + output, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ZardButtonComponent } from '@common-ui/ui/button'; +import { ZardInputDirective } from '@common-ui/ui/input'; +import { ZardSelectImports } from '@common-ui/ui/select'; + +import { AllocationApiService, AllocationFlavor } from './allocation-api.service'; +import { NotificationService } from '@/app-modules/core/services/notification.service'; +import { SessionStore } from '@/app-modules/core/state/session.store'; +import { numOrNull } from '@/app-modules/core/utils/select-value'; + +/** Context handed down by the allocation/reallocation parents (old `outboundCallRequests`). */ +export interface AllocationContext { + /** Raw picker dates (the old app posted these instants on the grievance allocate too). */ + startDate?: Date; + endDate?: Date; + language?: string; + noOfRecords?: number; + assignedUserID?: number | string; + isAllocate?: boolean; +} + +export interface FilterAgent { + agentName: string; + roleID: number | string; + languageName: string; + assignedUserID: number | string; +} + +interface RoleRow { + roleID?: number | string; + roleName?: string; +} + +interface AgentRow { + userID?: number | string; + firstName?: string; + lastName?: string; +} + +/** + * Shared allocate-to-agents form (the old app had three near-identical copies: + * `outbound-allocate-records`, grievance and everwell `*-allocate-records`). + * Role → agents (multi) → count, then the flavor's allocation endpoint. + */ +@Component({ + selector: 'app-allocate-records', + imports: [ReactiveFormsModule, ZardButtonComponent, ZardInputDirective, ...ZardSelectImports], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (context().language) { +
+ Language + {{ context().language }} +
+ } + + + + + @if (noAgents()) { +

No agents available for this language.

+ } +
+ `, +}) +export class AllocateRecordsComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly api = inject(AllocationApiService); + private readonly notify = inject(NotificationService); + private readonly sessionStore = inject(SessionStore); + + readonly flavor = input.required(); + readonly context = input.required(); + readonly filterAgent = input(null); + readonly allocated = output(); + + protected readonly roles = signal([]); + protected readonly agents = signal([]); + protected readonly noAgents = signal(false); + protected readonly saving = signal(false); + + private readonly serviceId = computed(() => this.sessionStore.currentServiceId()); + private recordList: unknown[] = []; + private initialCount = 0; + + protected readonly form = this.fb.group({ + roleID: this.fb.control(null, Validators.required), + agents: this.fb.control([], Validators.required), + allocateNo: this.fb.control(null, Validators.required), + }); + + constructor() { + effect(() => { + this.context(); + this.onContextChange(); + }); + } + + ngOnInit(): void { + const serviceId = this.serviceId(); + if (serviceId == null) { + return; + } + this.api.getRoles(serviceId).subscribe({ + next: (res) => { + const rows = Array.isArray(res?.data) ? (res.data as RoleRow[]) : []; + this.roles.set( + rows.filter( + (r) => + r.roleName?.toLowerCase() !== 'supervisor' && + r.roleName?.toLowerCase() !== 'provideradmin', + ), + ); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load roles', 'error'), + }); + } + + private onContextChange(): void { + const ctx = this.context(); + const serviceId = this.serviceId(); + this.form.reset({ agents: [] }); + this.agents.set([]); + this.noAgents.set(false); + this.recordList = []; + + if (this.flavor() === 'grievance') { + // The old grievance child never fetched a list — count comes with the context + // (allocateNo prefill happens on role change, like the old app). + this.initialCount = ctx.noOfRecords ?? 0; + } else if (serviceId != null) { + this.fetchRecords(ctx, serviceId); + } + + const agent = this.filterAgent(); + if (agent) { + this.form.patchValue({ roleID: String(agent.roleID) }); + this.loadAgents(agent.roleID, agent.languageName); + } + } + + private fetchRecords(ctx: AllocationContext, serviceId: number): void { + const options: Parameters[2] = ctx.assignedUserID + ? { assignedUserID: ctx.assignedUserID, preferredLanguageName: ctx.language } + : { + filterStartDate: this.normalized(ctx.startDate, 'start'), + filterEndDate: this.normalized(ctx.endDate, 'end'), + preferredLanguageName: ctx.language, + }; + this.api.listRecords(this.flavor(), serviceId, options).subscribe({ + next: (res) => { + this.recordList = Array.isArray(res?.data) ? res.data : []; + // Old generic/everwell children never prefilled allocateNo from the fetch; and the + // old everwell child's upper clamp never fired (its initialCount read the envelope + // and stayed undefined), so everwell keeps no upper bound. + this.initialCount = this.flavor() === 'everwell' ? Infinity : this.recordList.length; + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load records', 'error'), + }); + } + + /** Old child re-normalized the picker date to the day edge before posting (ISO string). */ + private normalized(date: Date | undefined, edge: 'start' | 'end'): string | undefined { + if (!date) { + return undefined; + } + const d = new Date(date); + if (edge === 'start') { + d.setHours(0, 0, 0, 0); + } else { + d.setHours(23, 59, 59, 0); + } + return d.toJSON(); + } + + // 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 onRoleChange(value: string | string[]): void { + const roleID = value as string; + // Only the old grievance child prefilled allocateNo on role change. + if (this.flavor() === 'grievance') { + this.form.patchValue({ agents: [], allocateNo: this.context().noOfRecords ?? 0 }); + } else { + this.form.patchValue({ agents: [] }); + } + if (roleID) { + this.loadAgents(roleID, this.context().language); + } + } + + private loadAgents(roleID: number | string, languageName?: string): void { + const serviceId = this.serviceId(); + if (serviceId == null) { + return; + } + this.api.getAgents(serviceId, numOrNull(String(roleID)) ?? roleID, languageName).subscribe({ + next: (res) => { + let rows = Array.isArray(res?.data) ? (res.data as AgentRow[]) : []; + const excluded = this.filterAgent(); + if (excluded) { + rows = rows.filter((a) => `${a.firstName} ${a.lastName}` !== excluded.agentName); + } + this.agents.set(rows); + this.noAgents.set(rows.length === 0); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load agents', 'error'), + }); + } + + /** Old `OnSelectChange` — split the pool evenly across the selected agents. Deselecting + * all resets to the full pool (old grievance branch; the old generic/everwell divided by + * zero here — declared non-replication). */ + protected onAgentsChange(value: string | string[]): void { + const selected = Array.isArray(value) ? value : []; + const pool = + this.flavor() === 'grievance' ? (this.context().noOfRecords ?? 0) : this.recordList.length; + if (selected.length > 0) { + const share = Math.floor(pool / selected.length); + this.initialCount = share; + this.form.patchValue({ allocateNo: share }); + } else { + this.form.patchValue({ allocateNo: pool }); + } + } + + /** Old `validate` — flavor-specific: grievance resets any out-of-range value to 0; + * generic clears below 1 and clamps above the pool; everwell only clears below 1. */ + protected clampAllocateNo(): void { + const value = this.form.controls.allocateNo.value; + if (value == null) { + return; + } + if (this.flavor() === 'grievance') { + if (value < 1 || value > this.initialCount) { + this.form.patchValue({ allocateNo: 0 }); + } + return; + } + if (value < 1) { + this.form.patchValue({ allocateNo: null }); + } else if (value > this.initialCount) { + this.form.patchValue({ allocateNo: this.initialCount }); + } + } + + protected allocate(): void { + const v = this.form.getRawValue(); + const ctx = this.context(); + const agentIds = (v.agents ?? []).map((id) => numOrNull(id) ?? id); + this.saving.set(true); + + const request$ = + this.flavor() === 'grievance' + ? this.api.allocateGrievance( + { + // Old app posted the RAW picker instants, not the count-query boundaries. + startDate: ctx.startDate?.toJSON(), + endDate: ctx.endDate?.toJSON(), + providerServiceMapId: this.serviceId(), + language: ctx.language, + fromUserId: ctx.assignedUserID, + roleID: numOrNull(v.roleID), + touserID: agentIds, + allocateNo: v.allocateNo, + }, + ctx.isAllocate !== false, + ) + : this.flavor() === 'everwell' + ? this.api.allocateEverwell({ + roleID: numOrNull(v.roleID) ?? '', + agentId: agentIds, + allocateNo: v.allocateNo ?? 0, + outboundCallRequests: this.recordList, + }) + : this.api.allocateGeneric({ + roleID: numOrNull(v.roleID) ?? '', + userID: agentIds, + allocateNo: v.allocateNo ?? 0, + outboundCallRequests: this.recordList, + }); + + request$.subscribe({ + next: () => { + this.saving.set(false); + this.notify.alert('Call allocated successfully', 'success'); + this.form.reset({ agents: [] }); + this.allocated.emit(); + }, + error: (err: { status?: number; errorMessage?: string }) => { + this.saving.set(false); + // Old generic/grievance handlers alerted the bare HTTP status; everwell alerted + // the error message. + this.notify.alert( + this.flavor() === 'everwell' + ? (err?.errorMessage ?? 'error') + : String(err?.status ?? 'error'), + 'error', + ); + }, + }); + } +} diff --git a/src/app/app-modules/supervisor/allocation/allocation-api.service.ts b/src/app/app-modules/supervisor/allocation/allocation-api.service.ts new file mode 100644 index 0000000..c8bf72f --- /dev/null +++ b/src/app/app-modules/supervisor/allocation/allocation-api.service.ts @@ -0,0 +1,223 @@ +/* + * 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 { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ApiResponse } from '@/app-modules/core/models'; +import { ConfigService } from '@/app-modules/core/services/config.service'; + +export type AllocationFlavor = 'generic' | 'grievance' | 'everwell'; + +/** Per-language count row (`{language, count}`) returned by every count endpoint. */ +export interface LanguageCountRow { + language?: string; + count?: number; + [key: string]: unknown; +} + +/** Parse a `YYYY-MM-DD` input value as LOCAL midnight (the old pickers produced local + * Dates; `new Date('YYYY-MM-DD')` would parse UTC and shift the day west of UTC). */ +export function localDate(value: string): Date { + const [y, m, d] = value.split('-').map(Number); + return new Date(y, m - 1, d); +} + +/** + * Old app's UTC-offset-shifted day boundary strings (`toJSON().slice(0,10) + "T…Z"`) — + * the exact query-window format every allocation screen posted. + */ +export function dayBoundary(date: Date, edge: 'start' | 'end' | 'end999'): string { + const day = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000) + .toJSON() + .slice(0, 10); + return edge === 'start' + ? `${day}T00:00:00.000Z` + : edge === 'end' + ? `${day}T23:59:59.000Z` + : `${day}T23:59:59.999Z`; +} + +/** + * Allocation/reallocation endpoints for the three outbound flavors (old + * OutboundCallAllocationService / OutboundSearchRecordService / OutboundReAllocationService). + * Bodies are byte-faithful — note the everwell `providerServiceMapId`/`agentId` casing and + * the grievance `touserID`/`fromUserId` keys. + */ +@Injectable({ providedIn: 'root' }) +export class AllocationApiService { + private readonly http = inject(HttpClient); + private readonly config = inject(ConfigService); + + private post(path: string, body: unknown): Observable { + return this.http.post(`${this.config.commonBaseURL}${path}`, body); + } + + /** POST user/getRolesByProviderID. */ + getRoles(providerServiceMapID: number): Observable { + return this.post('user/getRolesByProviderID', { providerServiceMapID }); + } + + /** POST user/getUsersByProviderID — RoleID/languageName only when present (old body). */ + getAgents( + providerServiceMapID: number, + roleID?: number | string, + languageName?: string, + ): Observable { + const body: Record = { providerServiceMapID }; + if (roleID) { + body['RoleID'] = roleID; + } + if (languageName) { + body['languageName'] = languageName; + } + return this.post('user/getUsersByProviderID', body); + } + + /** Unallocated per-language counts for a date window. */ + countUnallocated( + flavor: AllocationFlavor, + providerServiceMapID: number, + filterStartDate: string, + filterEndDate: string, + preferredLanguageName?: string, + ): Observable { + if (flavor === 'everwell') { + return this.post('everwellCall/outboundCallCount', { + providerServiceMapId: providerServiceMapID, + filterStartDate, + filterEndDate, + preferredLanguageName, + }); + } + const body = { providerServiceMapID, filterStartDate, filterEndDate, preferredLanguageName }; + return this.post( + flavor === 'grievance' ? 'unallocatedGrievanceCount' : 'call/outboundCallCount', + body, + ); + } + + /** An agent's per-language counts (reallocation screens). No date filters: the old + * pickers were `display:none` dead UI on ALL flavors, so none were ever sent. */ + countByAgent( + flavor: AllocationFlavor, + providerServiceMapID: number, + userID: number | string, + ): Observable { + if (flavor === 'grievance') { + return this.post('allocatedGrievanceRecordsCount', { providerServiceMapID, userID }); + } + if (flavor === 'everwell') { + return this.post('everwellCall/outboundCallCount', { + providerServiceMapId: providerServiceMapID, + agentId: userID, + }); + } + return this.post('call/outboundCallCount', { providerServiceMapID, assignedUserID: userID }); + } + + /** + * The records to allocate — date-window scoped (allocation) or agent-scoped + * (reallocation; the old service dropped the date fields on the agent branch). + */ + listRecords( + flavor: AllocationFlavor, + providerServiceMapID: number, + options: { + filterStartDate?: string; + filterEndDate?: string; + preferredLanguageName?: string; + assignedUserID?: number | string; + }, + ): Observable { + if (flavor === 'everwell') { + const body: Record = { providerServiceMapId: providerServiceMapID }; + if (options.assignedUserID) { + body['agentId'] = options.assignedUserID; + body['preferredLanguageName'] = options.preferredLanguageName; + } else { + body['filterStartDate'] = options.filterStartDate; + body['filterEndDate'] = options.filterEndDate; + body['preferredLanguageName'] = options.preferredLanguageName; + } + return this.post('everwellCall/outboundCallList', body); + } + const body: Record = { providerServiceMapID, is1097: true }; + if (options.assignedUserID) { + body['assignedUserID'] = options.assignedUserID; + body['preferredLanguageName'] = options.preferredLanguageName; + } else { + body['filterStartDate'] = options.filterStartDate; + body['filterEndDate'] = options.filterEndDate; + body['preferredLanguageName'] = options.preferredLanguageName; + } + return this.post('call/outboundCallList', body); + } + + /** Raw list post for the move-to-bin path (the old code posted its reqObj verbatim). */ + listForBin(flavor: AllocationFlavor, body: Record): Observable { + return this.post( + flavor === 'everwell' ? 'everwellCall/outboundCallList' : 'call/outboundCallList', + body, + ); + } + + /** POST call/outboundAllocation — old body = the whole allocate form. */ + allocateGeneric(body: { + roleID: number | string; + userID: (number | string)[]; + allocateNo: number; + outboundCallRequests: unknown[] | null; + }): Observable { + return this.post('call/outboundAllocation', body); + } + + /** POST everwellCall/outboundAllocation — everwell uses the `agentId` key. */ + allocateEverwell(body: { + roleID: number | string; + agentId: (number | string)[]; + allocateNo: number; + outboundCallRequests: unknown[] | null; + }): Observable { + return this.post('everwellCall/outboundAllocation', body); + } + + /** POST allocateGrievances / reallocateGrievances (isAllocate picks the endpoint). */ + allocateGrievance(body: Record, isAllocate: boolean): Observable { + return this.post(isAllocate ? 'allocateGrievances' : 'reallocateGrievances', body); + } + + /** Move an agent's records back to the unallocated bin. */ + moveToBin( + flavor: AllocationFlavor, + body: Record, + ): Observable { + const path = + flavor === 'grievance' + ? 'moveToBin' + : flavor === 'everwell' + ? 'everwellCall/resetOutboundCall' + : 'call/resetOutboundCall'; + return this.post(path, body); + } +} diff --git a/src/app/app-modules/supervisor/allocation/call-allocation.component.ts b/src/app/app-modules/supervisor/allocation/call-allocation.component.ts new file mode 100644 index 0000000..016705d --- /dev/null +++ b/src/app/app-modules/supervisor/allocation/call-allocation.component.ts @@ -0,0 +1,216 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + OnInit, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { ZardButtonComponent } from '@common-ui/ui/button'; +import { ZardInputDirective } from '@common-ui/ui/input'; +import { ZardSelectImports } from '@common-ui/ui/select'; + +import { + AllocationApiService, + AllocationFlavor, + dayBoundary, + LanguageCountRow, + localDate, +} from './allocation-api.service'; +import { AllocateRecordsComponent, AllocationContext } from './allocate-records.component'; +import { CallApiService } from '@/app-modules/core/services/call-api.service'; +import { NotificationService } from '@/app-modules/core/services/notification.service'; +import { SessionStore } from '@/app-modules/core/state/session.store'; + +/** + * Unallocated-calls allocation screen, shared by the generic (case 12), grievance (29) and + * everwell (26) supervisor pages (the old app had three near-identical components). + * Default window = today → today+7 (old boundary strings). + */ +@Component({ + selector: 'app-call-allocation', + imports: [ + ReactiveFormsModule, + ZardButtonComponent, + ZardInputDirective, + AllocateRecordsComponent, + ...ZardSelectImports, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

+ Default shows calls between today and the next seven days. +

+
+ + + + +
+ +
+ + + + + + + + + + @for (row of countRows(); track $index) { + + + + + + } @empty { + + + + } + +
LanguageNo. of Records
{{ row.language }}{{ row.count }} + @if (row.language !== 'All') { + + } +
+ No Records Found +
+
+ + @if (allocationContext(); as ctx) { + + } +
+ `, +}) +export class CallAllocationComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly api = inject(AllocationApiService); + private readonly callApi = inject(CallApiService); + private readonly notify = inject(NotificationService); + private readonly sessionStore = inject(SessionStore); + + readonly flavor = input.required(); + + protected readonly countRows = signal([]); + protected readonly languages = signal<{ languageID?: number; languageName?: string }[]>([]); + protected readonly allocationContext = signal(null); + + private readonly serviceId = computed(() => this.sessionStore.currentServiceId()); + private lastWindow: { start: string; end: string; language?: string } | null = null; + + protected readonly form = this.fb.group({ + startDate: this.fb.control(null), + endDate: this.fb.control(null), + language: this.fb.control('', { nonNullable: true }), + }); + + ngOnInit(): void { + const start = new Date(); + const end = new Date(); + end.setDate(end.getDate() + 7); + const asInput = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + this.form.patchValue({ startDate: asInput(start), endDate: asInput(end) }); + this.fetchCounts(dayBoundary(start, 'start'), dayBoundary(end, 'end')); + this.callApi.getLanguages().subscribe({ + next: (res) => this.languages.set(Array.isArray(res?.data) ? res.data : []), + error: () => this.languages.set([]), + }); + } + + protected search(): void { + const v = this.form.getRawValue(); + if (!v.startDate || !v.endDate) { + return; + } + this.allocationContext.set(null); + this.fetchCounts( + dayBoundary(localDate(v.startDate), 'start'), + dayBoundary(localDate(v.endDate), 'end'), + v.language || undefined, + ); + } + + private fetchCounts(start: string, end: string, language?: string): void { + const serviceId = this.serviceId(); + if (serviceId == null) { + return; + } + this.lastWindow = { start, end, language }; + this.api.countUnallocated(this.flavor(), serviceId, start, end, language).subscribe({ + next: (res) => this.countRows.set(Array.isArray(res?.data) ? (res.data as LanguageCountRow[]) : []), + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load counts', 'error'), + }); + } + + protected startAllocation(row: LanguageCountRow): void { + const v = this.form.getRawValue(); + this.allocationContext.set({ + startDate: v.startDate ? localDate(v.startDate) : undefined, + endDate: v.endDate ? localDate(v.endDate) : undefined, + language: row.language, + noOfRecords: row.count, + isAllocate: true, + }); + } + + protected refresh(): void { + this.allocationContext.set(null); + // Old parents refetched WITHOUT the language filter after an allocation. + if (this.lastWindow) { + this.fetchCounts(this.lastWindow.start, this.lastWindow.end); + } + } +} diff --git a/src/app/app-modules/supervisor/allocation/call-reallocation.component.ts b/src/app/app-modules/supervisor/allocation/call-reallocation.component.ts new file mode 100644 index 0000000..54e1b0d --- /dev/null +++ b/src/app/app-modules/supervisor/allocation/call-reallocation.component.ts @@ -0,0 +1,310 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + OnInit, + signal, +} from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { ZardButtonComponent } from '@common-ui/ui/button'; +import { ZardInputDirective } from '@common-ui/ui/input'; +import { ZardSelectImports } from '@common-ui/ui/select'; + +import { + AllocationApiService, + AllocationFlavor, + LanguageCountRow, +} from './allocation-api.service'; +import { + AllocateRecordsComponent, + AllocationContext, + FilterAgent, +} from './allocate-records.component'; +import { NotificationService } from '@/app-modules/core/services/notification.service'; +import { SessionStore } from '@/app-modules/core/state/session.store'; +import { numOrNull } from '@/app-modules/core/utils/select-value'; + +interface RoleRow { + roleID?: number | string; + roleName?: string; +} + +interface AgentRow { + userID?: number | string; + firstName?: string; + lastName?: string; +} + +/** + * Reallocation screen shared by the generic (case 13), grievance (30) and everwell (27) + * supervisor pages: role → agent → per-language counts, then reallocate to another agent + * or move back to the unallocated bin. No date filters — the old pickers were + * display:none dead UI on every flavor and were never sent. + */ +@Component({ + selector: 'app-call-reallocation', + imports: [ + ReactiveFormsModule, + ZardButtonComponent, + ZardInputDirective, + AllocateRecordsComponent, + ...ZardSelectImports, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ + +
+ + @if (countRows().length) { +
+ + + + + + + + + + @for (row of countRows(); track $index) { + + + + + + } + +
LanguageNo. of Records
{{ row.language }}{{ row.count }} + @if (row.language !== 'All') { +
+ + +
+ } +
+
+ } + + @if (reallocationContext(); as ctx) { + + } +
+ `, +}) +export class CallReallocationComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly api = inject(AllocationApiService); + private readonly notify = inject(NotificationService); + private readonly sessionStore = inject(SessionStore); + + readonly flavor = input.required(); + + protected readonly roles = signal([]); + protected readonly agents = signal([]); + protected readonly countRows = signal([]); + protected readonly reallocationContext = signal(null); + protected readonly selectedAgent = signal(null); + + private readonly serviceId = computed(() => this.sessionStore.currentServiceId()); + + protected readonly form = this.fb.group({ + roleID: this.fb.control(null), + agentId: this.fb.control(null), + }); + + ngOnInit(): void { + const serviceId = this.serviceId(); + if (serviceId == null) { + return; + } + this.api.getRoles(serviceId).subscribe({ + next: (res) => { + const rows = Array.isArray(res?.data) ? (res.data as RoleRow[]) : []; + this.roles.set( + rows.filter( + (r) => + r.roleName?.trim().toUpperCase() !== 'PROVIDERADMIN' && + r.roleName?.trim().toUpperCase() !== 'SUPERVISOR', + ), + ); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load roles', 'error'), + }); + } + + // 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 onRoleChange(value: string | string[]): void { + const serviceId = this.serviceId(); + const roleID = value as string; + this.agents.set([]); + this.countRows.set([]); + this.reallocationContext.set(null); + this.form.patchValue({ agentId: null }); + if (serviceId == null || !roleID) { + return; + } + // Old reallocation agents fetch carried no language filter (asymmetric vs allocation). + this.api.getAgents(serviceId, numOrNull(roleID) ?? roleID).subscribe({ + next: (res) => this.agents.set(Array.isArray(res?.data) ? (res.data as AgentRow[]) : []), + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load agents', 'error'), + }); + } + + protected onAgentSelected(value: string | string[]): void { + const serviceId = this.serviceId(); + const agentId = value as string; + this.reallocationContext.set(null); + if (serviceId == null || !agentId) { + return; + } + this.api + .countByAgent(this.flavor(), serviceId, numOrNull(agentId) ?? agentId) + .subscribe({ + next: (res) => { + const rows = Array.isArray(res?.data) ? (res.data as LanguageCountRow[]) : []; + this.countRows.set(rows); + if (rows.length === 0) { + this.notify.alert('No records available', 'info'); + } + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load records', 'error'), + }); + } + + private agentName(): string { + const agentId = this.form.controls.agentId.value; + const agent = this.agents().find((a) => String(a.userID) === agentId); + return `${agent?.firstName ?? ''} ${agent?.lastName ?? ''}`.trim(); + } + + protected startReallocation(row: LanguageCountRow): void { + const agentId = this.form.controls.agentId.value ?? ''; + this.selectedAgent.set({ + agentName: this.agentName(), + roleID: this.form.controls.roleID.value ?? '', + languageName: row.language ?? '', + assignedUserID: numOrNull(agentId) ?? agentId, + }); + this.reallocationContext.set({ + language: row.language, + noOfRecords: row.count, + assignedUserID: numOrNull(agentId) ?? agentId, + isAllocate: false, + }); + } + + protected moveToBin(row: LanguageCountRow): void { + const serviceId = this.serviceId(); + const agentId = this.form.controls.agentId.value; + if (serviceId == null || !agentId) { + return; + } + const userID = numOrNull(agentId) ?? agentId; + + if (this.flavor() === 'grievance') { + // Old grievance bin posted directly, without a list fetch. + this.api + .moveToBin('grievance', { + providerServiceMapID: serviceId, + userID, + preferredLanguageName: row.language, + is1097: true, + noOfCalls: row.count, + }) + .subscribe({ + next: () => { + this.notify.alert('Moved to bin successfully', 'success'); + this.refresh(); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to move to bin', 'error'), + }); + return; + } + + // Generic/everwell: fetch the agent's records for the language, then reset by id. + const body: Record = + this.flavor() === 'everwell' + ? { providerServiceMapId: serviceId, agentId: userID, preferredLanguageName: row.language } + : { providerServiceMapID: serviceId, assignedUserID: userID, preferredLanguageName: row.language, is1097: true }; + this.api.listForBin(this.flavor(), body).subscribe({ + next: (res) => { + const rows = Array.isArray(res?.data) ? (res.data as Record[]) : []; + const binBody = + this.flavor() === 'everwell' + ? { eapiIds: rows.map((r) => r['eapiId']) } + : { outboundCallReqIDs: rows.map((r) => r['outboundCallReqID']) }; + this.api.moveToBin(this.flavor(), binBody).subscribe({ + next: () => { + this.notify.alert('Moved to bin successfully', 'success'); + this.refresh(); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to move to bin', 'error'), + }); + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load records', 'error'), + }); + } + + protected refresh(): void { + this.reallocationContext.set(null); + this.onAgentSelected(this.form.controls.agentId.value ?? ''); + } +} diff --git a/src/app/app-modules/supervisor/supervisor-shell.component.html b/src/app/app-modules/supervisor/supervisor-shell.component.html index da1fac6..b81f1e1 100644 --- a/src/app/app-modules/supervisor/supervisor-shell.component.html +++ b/src/app/app-modules/supervisor/supervisor-shell.component.html @@ -83,6 +83,24 @@ @case ('7') { } + @case ('12') { + + } + @case ('13') { + + } + @case ('26') { + + } + @case ('27') { + + } + @case ('29') { + + } + @case ('30') { + + } @default {
diff --git a/src/app/app-modules/supervisor/supervisor-shell.component.ts b/src/app/app-modules/supervisor/supervisor-shell.component.ts index fc596e3..c09ea7a 100644 --- a/src/app/app-modules/supervisor/supervisor-shell.component.ts +++ b/src/app/app-modules/supervisor/supervisor-shell.component.ts @@ -28,6 +28,8 @@ import { RouterLink } from '@angular/router'; import { ZardButtonComponent } from '@common-ui/ui/button'; import { menuImports } from '@common-ui/ui/menu'; +import { CallAllocationComponent } from './allocation/call-allocation.component'; +import { CallReallocationComponent } from './allocation/call-reallocation.component'; import { TelephonyIframeComponent } from './telephony-iframe.component'; import { ConfigService } from '@/app-modules/core/services/config.service'; import { SessionStore } from '@/app-modules/core/state/session.store'; @@ -56,8 +58,8 @@ const PAGES: Record = { '8': { label: 'Supervisor Configurations', arrives: 'a later phase (menu-less in the old app)' }, '9': { label: 'Call Type Report', arrives: 'Phase 7e' }, '10': { label: 'Knowledge Management', arrives: 'Phase 7g' }, - '12': { label: 'Outbound Call List', arrives: 'Phase 7d' }, - '13': { label: 'Outbound Call Re-Allocation', arrives: 'Phase 7d' }, + '12': { label: 'Outbound Call List' }, + '13': { label: 'Outbound Call Re-Allocation' }, '14': { label: 'Campaign Status' }, '15': { label: 'Caller Age Report', arrives: 'Phase 7e' }, '16': { label: 'Sexual Orientation Report', arrives: 'Phase 7e' }, @@ -69,11 +71,11 @@ const PAGES: Record = { '22': { label: 'Emergency Contacts', arrives: 'Phase 7f' }, '23': { label: 'Force Logout', arrives: 'Phase 7g' }, '24': { label: 'SMS Templates', arrives: 'Phase 7g' }, - '26': { label: 'Everwell Call Allocation', arrives: 'Phase 7d' }, - '27': { label: 'Everwell Call Re-Allocation', arrives: 'Phase 7d' }, + '26': { label: 'Everwell Call Allocation' }, + '27': { label: 'Everwell Call Re-Allocation' }, '28': { label: 'Everwell Guidelines Upload', arrives: 'Phase 8' }, - '29': { label: 'Grievance Outbound Call Allocation', arrives: 'Phase 7d' }, - '30': { label: 'Grievance Outbound Call Re-Allocation', arrives: 'Phase 7d' }, + '29': { label: 'Grievance Outbound Call Allocation' }, + '30': { label: 'Grievance Outbound Call Re-Allocation' }, }; /** A dropdown entry: a page item, a labeled group heading (old submenu), or a divider. */ @@ -138,7 +140,15 @@ const CONFIGURATIONS_MENU: MenuEntry[] = [ */ @Component({ selector: 'app-supervisor-shell', - imports: [NgTemplateOutlet, RouterLink, ZardButtonComponent, TelephonyIframeComponent, ...menuImports], + imports: [ + NgTemplateOutlet, + RouterLink, + ZardButtonComponent, + TelephonyIframeComponent, + CallAllocationComponent, + CallReallocationComponent, + ...menuImports, + ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './supervisor-shell.component.html', })