diff --git a/pom.xml b/pom.xml index c6b72b85..4c4657f4 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.iemr.admin-ui admin-ui - 3.8.2 + 3.12.0 Admin-UI Piramal - admin: Module ui diff --git a/src/app/app-provider-admin/provider-admin/activities/activities.module.ts b/src/app/app-provider-admin/provider-admin/activities/activities.module.ts index da4ab8d7..1cc5471e 100644 --- a/src/app/app-provider-admin/provider-admin/activities/activities.module.ts +++ b/src/app/app-provider-admin/provider-admin/activities/activities.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { ScrollingModule } from '@angular/cdk/scrolling'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ProviderAdminRoleService } from './services/state-serviceline-role.service'; import { MaterialModule } from 'src/app/core/material.module'; @@ -25,6 +26,7 @@ import { RoleMasterComponent } from './role-master/provider-admin-role-master.co import { ServicelineCdssMappingComponent } from './serviceline-cdss-mapping/servicelineCdssMapping.component'; import { SpecialistMappingComponent } from './specialist-mapping/specialist-mapping.component'; import { WorkLocationMappingComponent } from './work-location-mapping/work-location-mapping.component'; +import { FastBulkSelectDirective } from './work-location-mapping/fast-bulk-select.directive'; import { UserFacilityMappingComponent } from './work-location-mapping/user-facility-mapping/user-facility-mapping.component'; import { VillageMasterService } from 'src/app/core/services/adminServices/AdminVillage/village-master-service.service'; import { CommonServices } from 'src/app/core/services/inventory-services/commonServices'; @@ -123,6 +125,7 @@ import { CoreModule } from 'src/app/core/core.module'; InstituteDirectoryMasterComponent, EditInstituteDirectoryComponent, WorkLocationMappingComponent, + FastBulkSelectDirective, FeedbackComplaintNatureMasterComponent, EditFeedbackNatureModalComponent, NatureOfComplaintCategoryMappingComponent, @@ -161,6 +164,7 @@ import { CoreModule } from 'src/app/core/core.module'; ], imports: [ CommonModule, + ScrollingModule, MaterialModule, FormsModule, ReactiveFormsModule, diff --git a/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.html b/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.html index da5bb93f..1cd7bfe7 100644 --- a/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.html +++ b/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.html @@ -56,6 +56,12 @@

Bulk User Upload

File size should not exceed {{ maxFileSize }}MB +
+ {{ pendingFileName }}: {{ pendingRowCount }} user(s) ready. + Click Submit to upload. +
Bulk User Upload Back
+
+ +
diff --git a/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.ts b/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.ts index 7beb6721..2f785d83 100644 --- a/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/employee-master-bulk-upload/employee-master-bulk-upload.component.ts @@ -22,6 +22,8 @@ import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; }) export class EmployeeMasterBulkUploadComponent { @Output() closeUpload = new EventEmitter(); + // Emitted after users are created, so the user list can reload. + @Output() usersUploaded = new EventEmitter(); objs = new MatTableDataSource(); paginator!: MatPaginator; @@ -56,6 +58,10 @@ export class EmployeeMasterBulkUploadComponent { fileuploadedCount = 0; filetotalCount = 0; errorloglist: any; + // File checked and ready, waiting for Submit. + pendingXml: string | null = null; + pendingFileName = ''; + pendingRowCount = 0; tableMode = true; editMode = false; @@ -128,57 +134,85 @@ export class EmployeeMasterBulkUploadComponent { const wb: XLSX.WorkBook = XLSX.read(binarystr, { type: 'binary' }); const wsname: string = wb.SheetNames[0]; const ws: XLSX.WorkSheet = wb.Sheets[wsname]; + const header: any[] = + (XLSX.utils.sheet_to_json(ws, { header: 1 })[0] as any[]) || []; + if (!header.includes('EmployeeId')) { + this.alertService.alert( + 'Employee Id column is missing. Download the latest Bulk User Template and fill Employee Id for every user.', + 'error', + ); + this.clearSelection(); + return; + } const data = XLSX.utils.sheet_to_json(ws); + if (!data.length) { + this.alertService.alert('The file has no users to upload', 'error'); + this.clearSelection(); + return; + } + // Checked and ready; the upload starts on Submit. + this.pendingXml = this.convertJsonToXml(data); + this.pendingFileName = file.name; + this.pendingRowCount = data.length; + this.successcount = false; + this.errorloglist = null; + }; + } - const xmlData = this.convertJsonToXml(data); - - this.showProgressBar = true; - this.blockSubcenterMappingService.memberBulkUploadXML(xmlData).subscribe( + submitUpload() { + if (!this.pendingXml || this.showProgressBar) return; + this.showProgressBar = true; + this.blockSubcenterMappingService + .memberBulkUploadXML(this.pendingXml) + .subscribe( (response: any) => { - if (response) { - this.fileuploadedCount = response.registeredUser; - this.filetotalCount = response.totalUser; - - if ( - Number(this.fileuploadedCount) !== Number(this.filetotalCount) - ) { - this.downloadFile(); - } - this.showProgressBar = false; - this.alertService.alert('File Uploaded', 'success'); - - this.resetFileInput(); - this.file = undefined; - this.successcount = true; - - this.errorloglist = response.error; - } else { - this.showProgressBar = false; - this.alertService.alert(response.errorMessage, 'error'); + this.showProgressBar = false; + if (!response || response.totalUser === undefined) { + this.alertService.alert( + response?.errorMessage || 'Upload failed', + 'error', + ); + return; + } + this.fileuploadedCount = response.registeredUser; + this.filetotalCount = response.totalUser; + if (Number(this.fileuploadedCount) !== Number(this.filetotalCount)) { + this.downloadFile(); + } + this.alertService.alert('File Uploaded', 'success'); + this.successcount = true; + this.errorloglist = response.error; + this.clearSelection(); + if (Number(this.fileuploadedCount) > 0) { + this.usersUploaded.emit(); } }, (error: HttpErrorResponse) => { + this.showProgressBar = false; if ( !navigator.onLine || (error.status === 0 && error.error instanceof ProgressEvent) ) { - this.showProgressBar = false; this.alertService.alert( 'Internet connection not available!', 'error', ); + } else { + this.alertService.alert( + error?.error?.errorMessage || 'Upload failed', + 'error', + ); } }, ); - (err: any) => { - this.showProgressBar = false; - this.alertService.alert(err.errorMessage, 'error'); - this.resetFileInput(); - this.file = undefined; - this.fileContent = null; - this.disableUpload = true; - }; - }; + } + + clearSelection() { + this.pendingXml = null; + this.pendingFileName = ''; + this.pendingRowCount = 0; + this.file = undefined; + this.resetFileInput(); } convertJsonToXml(json: any): string { @@ -231,6 +265,10 @@ export class EmployeeMasterBulkUploadComponent { } back() { + if (!this.pendingXml) { + this.closeUpload.emit(); + return; + } this.alertService .confirm( 'confirm', diff --git a/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.html b/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.html index 1832505a..89399ac1 100644 --- a/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.html +++ b/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.html @@ -1219,6 +1219,9 @@

Permanent Address

- +
diff --git a/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.ts b/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.ts index 7fdde2a2..4706c14b 100644 --- a/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/employee-master-new/employee-master-new.component.ts @@ -1616,8 +1616,17 @@ export class EmployeeMasterNewComponent implements OnInit { this.employeeMasterNewService .userActivationDeactivation(obj) .subscribe( - (res) => { - console.log('Activation or deactivation response', res.data); + (res: any) => { + if (res?.statusCode !== 200) { + this.dialogService.alert( + res?.errorMessage || + 'Failed to ' + + this.confirmMessage.toLowerCase() + + ' the user', + 'error', + ); + return; + } this.dialogService.alert( this.confirmMessage + 'd successfully', 'success', @@ -1625,7 +1634,16 @@ export class EmployeeMasterNewComponent implements OnInit { this.getAllUserDetails(); this.searchTerm = null; }, - (err) => console.log('error', err), + (err: any) => { + console.log('error', err); + this.dialogService.alert( + err?.errorMessage || + 'Failed to ' + + this.confirmMessage.toLowerCase() + + ' the user', + 'error', + ); + }, ); } }, @@ -1770,6 +1788,8 @@ export class EmployeeMasterNewComponent implements OnInit { this.employeeMasterUpload = false; this.tableMode = true; this.formMode = false; + // Show users created by the upload. + this.getAllUserDetails(); } private refreshFilteredData(rows: any[]) { diff --git a/src/app/app-provider-admin/provider-admin/activities/services/work-location-mapping.service.ts b/src/app/app-provider-admin/provider-admin/activities/services/work-location-mapping.service.ts index e28a5077..a967ffad 100644 --- a/src/app/app-provider-admin/provider-admin/activities/services/work-location-mapping.service.ts +++ b/src/app/app-provider-admin/provider-admin/activities/services/work-location-mapping.service.ts @@ -21,9 +21,18 @@ */ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { forkJoin, of } from 'rxjs'; +import { map } from 'rxjs/operators'; import { ConfigService } from 'src/app/core/services/config/config.service'; import { environment } from 'src/environments/environment'; +// A "select all" facility pick (e.g. every facility under several Nikshay +// TUs) can put 1000+ IDs on this GET's query string, which blows past the +// server/proxy request-line limit (~8KB) and comes back as a 400. The +// backend only exposes a comma-separated-param GET (no POST/body variant), +// so batch client-side instead of widening a single request. +const NIKSHAY_VILLAGES_BATCH_SIZE = 200; + /** * Author: krishna Gunti ( 378952 ) * Date: 05-03-2018 @@ -152,8 +161,29 @@ export class WorkLocationMapping { } getNikshayVillages(facilityIDs: any[]) { - return this.http.get( - `${environment.nikshayVillages_url}?facilityIDs=${facilityIDs.join(',')}`, + const ids = (facilityIDs || []).filter( + (id) => id !== null && id !== undefined, + ); + if (!ids.length) { + return of({ data: [] }); + } + const chunks: any[][] = []; + for (let i = 0; i < ids.length; i += NIKSHAY_VILLAGES_BATCH_SIZE) { + chunks.push(ids.slice(i, i + NIKSHAY_VILLAGES_BATCH_SIZE)); + } + return forkJoin( + chunks.map((chunk) => + this.http.get( + `${environment.nikshayVillages_url}?facilityIDs=${chunk.join(',')}`, + ), + ), + ).pipe( + map((responses: any[]) => ({ + data: responses.reduce( + (acc: any[], r: any) => acc.concat(r?.data || []), + [], + ), + })), ); } @@ -166,4 +196,23 @@ export class WorkLocationMapping { `${environment.nikshayUserMapping_url}?usrMappingID=${usrMappingID}`, ); } + + // Stop TB teams are the MMU vans of the user's state (AMM-2496). + getStopTBTeams(stateID: any, serviceProviderID: any) { + return this.http.get( + `${environment.stopTBTeams_url}?stateID=${stateID}&serviceProviderID=${serviceProviderID}`, + ); + } + + getStopTBUserTeam(userID: any, providerServiceMapID: any) { + return this.http.get( + `${environment.stopTBUserTeam_url}?userID=${userID}&providerServiceMapID=${providerServiceMapID}`, + ); + } + + getStopTBTeamMembers(vanID: any, providerServiceMapID: any) { + return this.http.get( + `${environment.stopTBTeamMembers_url}?vanID=${vanID}&providerServiceMapID=${providerServiceMapID}`, + ); + } } diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/fast-bulk-select.directive.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/fast-bulk-select.directive.ts new file mode 100644 index 00000000..7572ae98 --- /dev/null +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/fast-bulk-select.directive.ts @@ -0,0 +1,216 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * 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 { + AfterContentInit, + ContentChild, + Directive, + OnDestroy, + Self, +} from '@angular/core'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { MatSelect } from '@angular/material/select'; +import { Subscription } from 'rxjs'; + +/** + * Bulk selection for very large multi-selects (Stop TB Nikshay TU/Facility/ + * Village lists: thousands of options, and on UAT 1 lakh+ villages in one + * district). + * + * 1. Whenever the bound value is replaced (Select All, Deselect All, Edit + * prefill) or the option list changes (list reload, search filter), + * mat-select re-selects every value one by one: a linear search per value, + * then a full re-sort after each selected option. That is quadratic and + * froze low-end PCs for 5-45 s on large districts. Here values are matched + * to options through a Map/Set and the selection is sorted once. + * + * 2. When the options sit in a , only the rows + * on screen exist as mat-options, but mat-select assumes every option is + * rendered: on a click it would emit only the rendered selected options + * (dropping everything scrolled away) and it would show the field as empty + * when none of the selected rows are on screen. In that case this keeps + * the full bound value: a click adds/removes just the clicked option's + * value, `empty` reflects the full value, and the viewport is measured when + * the panel opens. Use a for the label text. + * + * Assumes mat-select's default identity compareWith (none of the Nikshay + * selects set one), and that sortComparator reads only `.value`. If + * Material's internals change, nothing is patched and mat-select's own code + * runs. + */ +@Directive({ + selector: 'mat-select[appFastBulkSelect]', +}) +export class FastBulkSelectDirective implements AfterContentInit, OnDestroy { + @ContentChild(CdkVirtualScrollViewport, { descendants: true }) + viewport?: CdkVirtualScrollViewport; + + private openSub?: Subscription; + + constructor(@Self() private select: MatSelect) { + const s: any = select; + const originalSetSelection = s._setSelectionByValue; + const originalPropagate = s._propagateChanges; + const originalOnSelect = s._onSelect; + if ( + typeof originalSetSelection !== 'function' || + typeof originalPropagate !== 'function' || + typeof originalOnSelect !== 'function' || + typeof s._sortValues !== 'function' + ) { + return; + } + const isVirtual = () => !!this.viewport; + + // Set of the current bound value, rebuilt only when the array changes + // (virtual lists re-sync the selection on every scroll). + let valueRef: any[] | null = null; + let valueSet = new Set(); + const setOf = (value: any[]) => { + if (value !== valueRef) { + valueRef = value; + valueSet = new Set(value); + } + return valueSet; + }; + + s._setSelectionByValue = function (value: any) { + if ( + !this.multiple || + !Array.isArray(value) || + !this.options || + !this._selectionModel + ) { + return originalSetSelection.call(this, value); + } + const sortValues = this._sortValues; + // Every option selected below triggers a full re-sort; sort once instead. + this._sortValues = () => undefined; + try { + this.options.forEach((option: any) => option.setInactiveStyles()); + this._selectionModel.clear(); + const matched = new Set(); + if (isVirtual()) { + // Few rendered options, possibly 1 lakh+ values: walk the options. + const selected = setOf(value); + this.options.forEach((option: any) => { + if (selected.has(option.value)) matched.add(option); + }); + } else { + const optionByValue = new Map(); + this.options.forEach((option: any) => { + const v = option.value; + if (v !== null && v !== undefined && !optionByValue.has(v)) { + optionByValue.set(v, option); + } + }); + value.forEach((v: any) => { + const option = optionByValue.get(v); + if (option) matched.add(option); + }); + } + if (matched.size) this._selectionModel.select(...matched); + } finally { + this._sortValues = sortValues; + } + this._sortValues(); + this._changeDetectorRef.markForCheck(); + }; + + // Remember which option a click toggled, so the full value can be + // updated for just that option. + let toggled: any = null; + s._onSelect = function (option: any, isUserInput: boolean) { + toggled = option; + try { + return originalOnSelect.call(this, option, isUserInput); + } finally { + toggled = null; + } + }; + + s._propagateChanges = function (fallbackValue: any) { + if (!isVirtual() || !this.multiple || !toggled) { + return originalPropagate.call(this, fallbackValue); + } + const value = toggled.value; + const next: any[] = Array.isArray(this._value) ? [...this._value] : []; + const at = next.indexOf(value); + if (toggled.selected && at === -1) { + // Insert at the position mat-select's own sort would give it. + const cmp = this.sortComparator; + let i = next.length; + if (cmp) { + let lo = 0; + let hi = next.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (cmp({ value: next[mid] }, { value }, []) <= 0) lo = mid + 1; + else hi = mid; + } + i = lo; + } + next.splice(i, 0, value); + } else if (!toggled.selected && at !== -1) { + next.splice(at, 1); + } + this._value = next; + this.valueChange.emit(next); + this._onChange(next); + this.selectionChange.emit(this._getChangeEvent(next)); + this._changeDetectorRef.markForCheck(); + }; + + // The selected rows may all be scrolled away; the field is only empty + // when the bound value is. + const emptyDescriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(s), + 'empty', + ); + if (emptyDescriptor?.get) { + const originalEmpty = emptyDescriptor.get; + Object.defineProperty(s, 'empty', { + configurable: true, + get() { + if (isVirtual() && s.multiple) { + return !(Array.isArray(s._value) && s._value.length); + } + return originalEmpty.call(s); + }, + }); + } + } + + ngAfterContentInit() { + // The viewport is created while the panel is closed (zero size); measure + // it once the panel is open, starting from the top like a normal list. + this.openSub = this.select.openedChange.subscribe((open: boolean) => { + if (open && this.viewport) { + this.viewport.checkViewportSize(); + this.viewport.scrollToIndex(0); + } + }); + } + + ngOnDestroy() { + this.openSub?.unsubscribe(); + } +} diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.css b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.css index b34a8f3f..b05839a9 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.css +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.css @@ -130,4 +130,34 @@ ::ng-deep .select-search-box input:focus { border-color: #3f51b5; -} \ No newline at end of file +} +/* Nikshay Facility/Village options: virtual scroll list inside the select + panel. Every row must be the same height for the list to scroll correctly, + so long names stay on one line ("...") with the full name on hover. */ +::ng-deep .nikshay-option-viewport { + width: 100%; + overflow-x: hidden; +} + +::ng-deep .nikshay-option-viewport .cdk-virtual-scroll-content-wrapper { + width: 100%; +} + +::ng-deep .nikshay-option-viewport .mat-mdc-option { + height: 48px; + min-height: 48px; +} + +::ng-deep .nikshay-option-viewport .mat-mdc-option .mdc-list-item__primary-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Stop TB team members under the Team field (AMM-2496) */ +.stoptb-team-members { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.html b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.html index ee73e5b1..9201557e 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.html +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.html @@ -431,6 +431,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayTUs" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayTUs" (selectionChange)="onNikshayTUChange()" (openedChange)="$event ? null : nikshayTUSearch = ''" @@ -461,6 +463,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayFacilities" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayFacilities" (selectionChange)="onNikshayFacilityChange()" (openedChange)="$event ? null : nikshayFacilitySearch = ''" @@ -477,9 +481,16 @@

allNikshayFacilitiesSelected ? 'Deselect All' : 'Select All' }} - - {{ item.facilityName }} - + {{ nikshayTriggerText(selectedNikshayFacilities, 'facilityName') }} + + + {{ item.facilityName }} + + @@ -491,6 +502,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayVillages" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayVillages" (openedChange)="$event ? null : nikshayVillageSearch = ''" required @@ -506,10 +519,37 @@

allNikshayVillagesSelected ? 'Deselect All' : 'Select All' }} - - {{ item.villageName }} + {{ nikshayTriggerText(selectedNikshayVillages, 'villageName') }} + + + {{ item.villageName }} + + + + + + +
+ + Select Team + + No team + + {{ team.vanName }} – {{ team.vanID }} ({{ team.parkingPlaceName }}) + + Members: {{ stopTBTeamMembers }} +
@@ -1017,6 +1057,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayTUs" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayTUsEdit" (selectionChange)="onNikshayTUChange()" (openedChange)="$event ? null : nikshayTUSearch = ''" @@ -1046,6 +1088,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayFacilities" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayFacilitiesEdit" (selectionChange)="onNikshayFacilityChange()" (openedChange)="$event ? null : nikshayFacilitySearch = ''" @@ -1062,9 +1106,16 @@

allNikshayFacilitiesSelected ? 'Deselect All' : 'Select All' }} - - {{ item.facilityName }} - + {{ nikshayTriggerText(selectedNikshayFacilities, 'facilityName') }} + + + {{ item.facilityName }} + + @@ -1075,6 +1126,8 @@

multiple [style.width]="'100%'" [(ngModel)]="selectedNikshayVillages" + [sortComparator]="sortNikshayOptions" + appFastBulkSelect name="nikshayVillagesEdit" (openedChange)="$event ? null : nikshayVillageSearch = ''" required @@ -1090,10 +1143,37 @@

allNikshayVillagesSelected ? 'Deselect All' : 'Select All' }} - - {{ item.villageName }} + {{ nikshayTriggerText(selectedNikshayVillages, 'villageName') }} + + + {{ item.villageName }} + + + + + + +
+ + Select Team + + No team + + {{ team.vanName }} – {{ team.vanID }} ({{ team.parkingPlaceName }}) + + Members: {{ stopTBTeamMembers }} +
diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts index 9f935f4c..ad75c8d8 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.spec.ts @@ -41,6 +41,10 @@ const FakeWorkLocationMapping = { getServices: (_userID: any) => of({ data: [] }), getMappedWorkLocationList: (_serviceProviderID: any) => of({ data: [] }), getUserName: (_serviceProviderID: any) => of({ data: [] }), + SaveWorkLocationMapping: (_data: any) => of({}), + UpdateWorkLocationMapping: (_data: any) => of({}), + DeleteWorkLocationMapping: (_data: any) => of({}), + DeleteWorkLocationMappingForTM: (_data: any) => of({}), }; const FakeVillageMasterService = {}; @@ -192,4 +196,91 @@ describe('WorkLocationMappingComponent', () => { expect(saved.villageName).toEqual(['Alamkeri']); }); }); + + // Regression coverage for the StopTB "Counsellor/Nurse silently + // dropped on Update" bug: (1) existingRoleIDs/newRoleIDs must compare + // equal regardless of which side is a numeric string, and (2) every + // newly-added role must go out in one SaveWorkLocationMapping call, + // not one racing call per role. + describe('updateGroupedWorkLocation — role add/keep/remove diffing', () => { + initTestBed(); + + const baseGroup = (roles: any[]): any => ({ + userID: 4426, + serviceID: 11, + serviceName: 'Stop TB', + roles, + }); + + beforeEach(() => { + component.RolesList = [ + { roleID: 168, roleName: 'Registration Officer' }, + { roleID: 169, roleName: 'Nurse' }, + { roleID: 170, roleName: 'Counsellor' }, + ]; + component.userID_duringEdit = 4426; + component.providerServiceMapID_duringEdit = 1734; + component.stateID_duringEdit = 2; + component.district_duringEdit = 1; + component.workLocationID_duringEdit = null; + component.isFacilityServicelineEdit = false; + component.isStopTBServicelineEdit = false; + component.editIsAshaSupervisor = false; + component.selectedNikshayTUs = []; + component.selectedNikshayFacilities = []; + component.selectedNikshayVillages = []; + component.selectedNikshayBlock = null; + component.serviceEditvillage = []; + component.editVillageArr = []; + }); + + it('batches two newly-added roles (Nurse + Counsellor) into a single SaveWorkLocationMapping call', () => { + const service = TestBed.inject(WorkLocationMapping); + const saveSpy = spyOn(service, 'SaveWorkLocationMapping').and.returnValue( + of({}), + ); + + component.editGroupedElement = baseGroup([]); + component.roleIDs_duringEdit = [169, 170]; + + component.updateGroupedWorkLocation({}); + + expect(saveSpy).toHaveBeenCalledTimes(1); + const [payload] = saveSpy.calls.mostRecent().args; + const sentRoleIDs = payload[0].previleges[0].ID.map((r: any) => r.roleID); + expect(sentRoleIDs).toEqual([169, 170]); + }); + + it('treats a numeric-string existing roleID as equal to a numeric selected roleID (no spurious add+remove)', () => { + const service = TestBed.inject(WorkLocationMapping); + const saveSpy = spyOn(service, 'SaveWorkLocationMapping').and.returnValue( + of({}), + ); + const updateSpy = spyOn( + service, + 'UpdateWorkLocationMapping', + ).and.returnValue(of({})); + const deleteSpy = spyOn( + service, + 'DeleteWorkLocationMapping', + ).and.returnValue(of({})); + + // Registration Officer's roleID arrives as a string from the + // work-location list endpoint; the checkbox sends it back as a number. + component.editGroupedElement = baseGroup([ + { + roleID: '168' as any, + uSRMappingID: 5907, + userServciceRoleDeleted: false, + }, + ]); + component.roleIDs_duringEdit = [168]; + + component.updateGroupedWorkLocation({}); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(saveSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts index 8113a2fd..afb36051 100644 --- a/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts +++ b/src/app/app-provider-admin/provider-admin/activities/work-location-mapping/work-location-mapping.component.ts @@ -36,8 +36,9 @@ import { ConfirmationDialogsService } from 'src/app/core/services/dialog/confirm import { WorkLocationMapping } from '../services/work-location-mapping.service'; import { SessionStorageService } from 'Common-UI/src/registrar/services/session-storage.service'; import { FacilityMasterService } from 'src/app/core/services/inventory-services/facilitytypemaster.service'; -import { forkJoin, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { MatOption } from '@angular/material/core'; +import { EMPTY, forkJoin, of, Subject, timer } from 'rxjs'; +import { catchError, debounce, switchMap, takeUntil } from 'rxjs/operators'; interface RoleEntry { roleID: number; @@ -224,9 +225,62 @@ export class WorkLocationMappingComponent isStopTBServiceline = false; nikshayStateList: any[] = []; nikshayDistrictList: any[] = []; - nikshayTUList: any[] = []; - nikshayFacilityList: any[] = []; - nikshayVillageList: any[] = []; + // TU/Facility/Village lists can hold thousands of entries (e.g. Pune MC: + // 5,327 facilities, Ganganagar: 3,058 villages). Each setter records every + // item's position once so nikshayOptionOrder can sort mat-select's selection + // cheaply — see sortNikshayOptions. + private _nikshayTUList: any[] = []; + private _nikshayFacilityList: any[] = []; + private _nikshayVillageList: any[] = []; + private nikshayOptionOrder = new WeakMap(); + + get nikshayTUList(): any[] { + return this._nikshayTUList; + } + set nikshayTUList(list: any[]) { + this._nikshayTUList = this.indexNikshayOptions(list); + } + + get nikshayFacilityList(): any[] { + return this._nikshayFacilityList; + } + set nikshayFacilityList(list: any[]) { + this._nikshayFacilityList = this.indexNikshayOptions(list); + } + + get nikshayVillageList(): any[] { + return this._nikshayVillageList; + } + set nikshayVillageList(list: any[]) { + this._nikshayVillageList = this.indexNikshayOptions(list); + } + + private indexNikshayOptions(list: any[]): any[] { + const items = list || []; + items.forEach((item: any, i: number) => { + if (item && typeof item === 'object') { + this.nikshayOptionOrder.set(item, i); + } + }); + return items; + } + + // mat-select (multiple) re-sorts its selection on every click, Select All + // and Edit prefill. Its default comparator calls options.indexOf() inside + // the sort — O(n^2 log n) — which freezes low-end PCs once a list reaches a + // few thousand entries. Sorting by the position recorded above keeps the + // same order at O(n log n). + sortNikshayOptions = (a: MatOption, b: MatOption): number => + (this.nikshayOptionOrder.get(a.value) ?? 0) - + (this.nikshayOptionOrder.get(b.value) ?? 0); + + // Ticking facilities/TUs one by one used to fire one request per click, + // and a slow earlier response could overwrite a newer one. Wait for the + // user to pause, then keep only the latest request. null cancels any + // pending request without touching the lists, so a late response can't + // overwrite a reset or an Edit prefill. + private nikshayTUSelection$ = new Subject(); + private nikshayFacilitySelection$ = new Subject(); selectedNikshayBlock: any = null; selectedNikshayTUs: any[] = []; selectedNikshayFacilities: any[] = []; @@ -258,36 +312,91 @@ export class WorkLocationMappingComponent ); } + // The getters below run on every change detection. With 1 lakh+ villages + // the filter/Set work is cached until the list, selection or search text + // changes (all three are always replaced, never mutated in place). + private nikshayMemo = new Map(); + private memoNikshay(key: string, deps: any[], compute: () => T): T { + const hit = this.nikshayMemo.get(key); + if (hit && hit.deps.every((d, i) => d === deps[i])) return hit.value; + const value = compute(); + this.nikshayMemo.set(key, { deps, value }); + return value; + } + get filteredNikshayFacilityList(): any[] { if (!this.nikshayFacilitySearch) return this.nikshayFacilityList; - const s = this.nikshayFacilitySearch.toLowerCase(); - const selectedIDs = new Set( - (this.selectedNikshayFacilities || []).map( - (f: any) => f.nikshayFacilityID, - ), - ); - return this.nikshayFacilityList.filter( - (f: any) => - selectedIDs.has(f.nikshayFacilityID) || - (f.facilityName || '').toLowerCase().includes(s), + return this.memoNikshay( + 'filteredFacilities', + [ + this.nikshayFacilityList, + this.selectedNikshayFacilities, + this.nikshayFacilitySearch, + ], + () => { + const s = this.nikshayFacilitySearch.toLowerCase(); + const selectedIDs = new Set( + (this.selectedNikshayFacilities || []).map( + (f: any) => f.nikshayFacilityID, + ), + ); + return this.nikshayFacilityList.filter( + (f: any) => + selectedIDs.has(f.nikshayFacilityID) || + (f.facilityName || '').toLowerCase().includes(s), + ); + }, ); } get filteredNikshayVillageList(): any[] { if (!this.nikshayVillageSearch) return this.nikshayVillageList; - const s = this.nikshayVillageSearch.toLowerCase(); - const selectedIDs = new Set( - (this.selectedNikshayVillages || []).map((v: any) => v.nikshayVillageID), - ); - return this.nikshayVillageList.filter( - (v: any) => - selectedIDs.has(v.nikshayVillageID) || - (v.villageName || '').toLowerCase().includes(s), + return this.memoNikshay( + 'filteredVillages', + [ + this.nikshayVillageList, + this.selectedNikshayVillages, + this.nikshayVillageSearch, + ], + () => { + const s = this.nikshayVillageSearch.toLowerCase(); + const selectedIDs = new Set( + (this.selectedNikshayVillages || []).map( + (v: any) => v.nikshayVillageID, + ), + ); + return this.nikshayVillageList.filter( + (v: any) => + selectedIDs.has(v.nikshayVillageID) || + (v.villageName || '').toLowerCase().includes(s), + ); + }, ); } + // Text shown in a closed Facility/Village field: the selected names joined + // like mat-select's own label. The field shows one line, so only the first + // names are joined. + nikshayTriggerText(selected: any[], nameKey: string): string { + return (selected || []) + .slice(0, 50) + .map((item: any) => item?.[nameKey]) + .join(', '); + } + + // Facility/Village options are drawn in a virtual scroll list (only the + // rows on screen exist), sized like the panel's normal list area. + readonly nikshayOptionHeight = 48; + nikshayViewportHeight(count: number): number { + return Math.min(count * this.nikshayOptionHeight, 180); + } + get allNikshayTUsSelected(): boolean { if (!this.nikshayTUList?.length) return false; + // Runs on every change detection — skip the Set build unless the counts + // could actually match. + if ((this.selectedNikshayTUs?.length || 0) < this.nikshayTUList.length) + return false; const selectedIDs = new Set( (this.selectedNikshayTUs || []).map((t: any) => t.nikshayTUID), ); @@ -303,13 +412,26 @@ export class WorkLocationMappingComponent get allNikshayFacilitiesSelected(): boolean { if (!this.nikshayFacilityList?.length) return false; - const selectedIDs = new Set( - (this.selectedNikshayFacilities || []).map( - (f: any) => f.nikshayFacilityID, - ), - ); - return this.nikshayFacilityList.every((f: any) => - selectedIDs.has(f.nikshayFacilityID), + // Runs on every change detection — skip the Set build unless the counts + // could actually match. + if ( + (this.selectedNikshayFacilities?.length || 0) < + this.nikshayFacilityList.length + ) + return false; + return this.memoNikshay( + 'allFacilitiesSelected', + [this.nikshayFacilityList, this.selectedNikshayFacilities], + () => { + const selectedIDs = new Set( + (this.selectedNikshayFacilities || []).map( + (f: any) => f.nikshayFacilityID, + ), + ); + return this.nikshayFacilityList.every((f: any) => + selectedIDs.has(f.nikshayFacilityID), + ); + }, ); } @@ -322,11 +444,26 @@ export class WorkLocationMappingComponent get allNikshayVillagesSelected(): boolean { if (!this.nikshayVillageList?.length) return false; - const selectedIDs = new Set( - (this.selectedNikshayVillages || []).map((v: any) => v.nikshayVillageID), - ); - return this.nikshayVillageList.every((v: any) => - selectedIDs.has(v.nikshayVillageID), + // Runs on every change detection — skip the Set build unless the counts + // could actually match. + if ( + (this.selectedNikshayVillages?.length || 0) < + this.nikshayVillageList.length + ) + return false; + return this.memoNikshay( + 'allVillagesSelected', + [this.nikshayVillageList, this.selectedNikshayVillages], + () => { + const selectedIDs = new Set( + (this.selectedNikshayVillages || []).map( + (v: any) => v.nikshayVillageID, + ), + ); + return this.nikshayVillageList.every((v: any) => + selectedIDs.has(v.nikshayVillageID), + ); + }, ); } @@ -363,6 +500,9 @@ export class WorkLocationMappingComponent this.selectedNikshayTUs = []; this.selectedNikshayFacilities = []; this.selectedNikshayVillages = []; + this.selectedStopTBTeamID = null; + this.stopTBTeamMembers = ''; + this.loadStopTBTeams(state?.stateID, state?.providerServiceMapID); if (!state?.stateName) return; // AMRIT's m_state spells this "Chattisgarh" (one 'h'); Nikshay's own data @@ -476,18 +616,8 @@ export class WorkLocationMappingComponent const tuIDs = (this.selectedNikshayTUs || []).map( (t: any) => t.nikshayTUID, ); - if (!tuIDs.length) return; - this.worklocationmapping - .getNikshayFacilities(tuIDs) - .pipe(takeUntil(this.destroy$)) - .subscribe( - (response: any) => { - this.nikshayFacilityList = response.data || []; - }, - () => { - this.alertService.alert('Failed to load Nikshay facilities', 'error'); - }, - ); + this.nikshayTUSelection$.next(tuIDs.length ? tuIDs : null); + this.nikshayFacilitySelection$.next(null); } // Called whenever the Facility multi-select changes @@ -497,21 +627,154 @@ export class WorkLocationMappingComponent const facilityIDs = (this.selectedNikshayFacilities || []).map( (f: any) => f.nikshayFacilityID, ); - if (!facilityIDs.length) return; + this.nikshayFacilitySelection$.next( + facilityIDs.length ? facilityIDs : null, + ); + } + + private initNikshayCascade() { + this.nikshayTUSelection$ + .pipe( + // cancels (null) go through at once; selections wait for a pause + debounce((ids: number[] | null) => (ids ? timer(400) : of(0))), + switchMap((tuIDs: number[] | null) => + !tuIDs + ? EMPTY + : this.worklocationmapping.getNikshayFacilities(tuIDs).pipe( + catchError(() => { + this.alertService.alert( + 'Failed to load Nikshay facilities', + 'error', + ); + return of(null); + }), + ), + ), + takeUntil(this.destroy$), + ) + .subscribe((response: any) => { + if (response) this.nikshayFacilityList = response.data || []; + }); + + this.nikshayFacilitySelection$ + .pipe( + // cancels (null) go through at once; selections wait for a pause + debounce((ids: number[] | null) => (ids ? timer(400) : of(0))), + switchMap((facilityIDs: number[] | null) => + !facilityIDs + ? EMPTY + : this.worklocationmapping.getNikshayVillages(facilityIDs).pipe( + catchError(() => { + this.alertService.alert( + 'Failed to load Nikshay villages', + 'error', + ); + return of(null); + }), + ), + ), + takeUntil(this.destroy$), + ) + .subscribe((response: any) => { + if (response) this.nikshayVillageList = response.data || []; + }); + } + + // ── Stop TB team (AMM-2496) ── + // A team is an MMU van of the user's state. A team has many users; a user + // is in one team per Stop TB service line, so picking a new team moves them. + stopTBTeamList: any[] = []; + selectedStopTBTeamID: number | null = null; + stopTBTeamMembers = ''; + private stopTBTeamPSM: any = null; + + get stopTBTeamName(): string | null { + const team = this.stopTBTeamList.find( + (t: any) => t.vanID === this.selectedStopTBTeamID, + ); + return team ? team.vanName : null; + } + + resetStopTBTeam() { + this.stopTBTeamList = []; + this.selectedStopTBTeamID = null; + this.stopTBTeamMembers = ''; + this.stopTBTeamPSM = null; + } + + loadStopTBTeams(stateID: any, providerServiceMapID: any) { + this.stopTBTeamList = []; + this.stopTBTeamPSM = providerServiceMapID; + if (!stateID) return; this.worklocationmapping - .getNikshayVillages(facilityIDs) + .getStopTBTeams(stateID, this.serviceProviderID) .pipe(takeUntil(this.destroy$)) .subscribe( (response: any) => { - this.nikshayVillageList = response.data || []; + this.stopTBTeamList = response?.data || []; }, () => { - this.alertService.alert('Failed to load Nikshay villages', 'error'); + this.alertService.alert('Failed to load teams', 'error'); }, ); } + onStopTBTeamChange() { + this.stopTBTeamMembers = ''; + if (!this.selectedStopTBTeamID || !this.stopTBTeamPSM) return; + this.worklocationmapping + .getStopTBTeamMembers(this.selectedStopTBTeamID, this.stopTBTeamPSM) + .pipe(takeUntil(this.destroy$)) + .subscribe((response: any) => { + this.stopTBTeamMembers = (response?.data || []) + .map((m: any) => { + const name = [m.firstName, m.lastName].filter(Boolean).join(' '); + return name ? `${name} [${m.userName}]` : m.userName; + }) + .join(', '); + }); + } + + // Edit: load the state's teams and pre-select the user's current team. + loadStopTBTeamForEdit() { + this.loadStopTBTeams( + this.stateID_duringEdit, + this.providerServiceMapID_duringEdit, + ); + if (!this.userID_duringEdit || !this.providerServiceMapID_duringEdit) + return; + this.worklocationmapping + .getStopTBUserTeam( + this.userID_duringEdit, + this.providerServiceMapID_duringEdit, + ) + .pipe(takeUntil(this.destroy$)) + .subscribe((response: any) => { + const vanID = response?.data?.vanID ?? null; + this.selectedStopTBTeamID = vanID; + this.onStopTBTeamChange(); + }); + } + + // Admin-API reports some failures (e.g. a team not in the user's state) + // as HTTP 200 with statusCode != 200 in the body. Returns that error + // message, or null when every response succeeded. + private apiFailure(responses: any): string | null { + const failed = ([] as any[]) + .concat(responses) + .find( + (r: any) => + r && typeof r.statusCode === 'number' && r.statusCode !== 200, + ); + return failed + ? failed.errorMessage || failed.status || 'Request failed' + : null; + } + resetNikshaySelection() { + this.resetStopTBTeam(); + this.nikshayTUSelection$.next(null); + this.nikshayFacilitySelection$.next(null); this.nikshayStateList = []; this.nikshayDistrictList = []; this.nikshayTUList = []; @@ -534,6 +797,7 @@ export class WorkLocationMappingComponent */ loadNikshayEditSelections() { this.resetNikshaySelection(); + this.loadStopTBTeamForEdit(); const splitIDs = (value: any): number[] => String(value || '') @@ -757,8 +1021,11 @@ export class WorkLocationMappingComponent .pipe(takeUntil(this.destroy$)) .subscribe((tuResponse: any) => { this.nikshayTUList = tuResponse.data || []; + // Set lookups: a district can have thousands of saved facilities and + // villages, and includes() inside filter() is quadratic. + const tuIDSet = new Set(existingTUIDs); this.selectedNikshayTUs = this.nikshayTUList.filter((t: any) => - existingTUIDs.includes(t.nikshayTUID), + tuIDSet.has(t.nikshayTUID), ); // "Select Block" is a single-select quick-add convenience over this // same TU list (see onNikshayBlockChange), but the saved BlockID IS @@ -779,8 +1046,9 @@ export class WorkLocationMappingComponent .pipe(takeUntil(this.destroy$)) .subscribe((facResponse: any) => { this.nikshayFacilityList = facResponse.data || []; + const facilityIDSet = new Set(existingFacilityIDs); this.selectedNikshayFacilities = this.nikshayFacilityList.filter( - (f: any) => existingFacilityIDs.includes(f.nikshayFacilityID), + (f: any) => facilityIDSet.has(f.nikshayFacilityID), ); if (!this.selectedNikshayFacilities.length) return; @@ -792,8 +1060,9 @@ export class WorkLocationMappingComponent .pipe(takeUntil(this.destroy$)) .subscribe((villResponse: any) => { this.nikshayVillageList = villResponse.data || []; + const villageIDSet = new Set(uniqueVillageIDs); this.selectedNikshayVillages = this.nikshayVillageList.filter( - (v: any) => uniqueVillageIDs.includes(v.nikshayVillageID), + (v: any) => villageIDSet.has(v.nikshayVillageID), ); }); }); @@ -939,6 +1208,7 @@ export class WorkLocationMappingComponent this.getProviderServices(this.userID); this.getAllMappedWorkLocations(); this.getUserName(this.serviceProviderID); + this.initNikshayCascade(); } ngOnDestroy() { @@ -1072,10 +1342,15 @@ export class WorkLocationMappingComponent const groupMap = new Map(); for (const row of flatRows) { - // Group rows for HWC/FLW service lines (including ASHA Supervisor) by user+block - // Other services get one row per entry + // Group rows for HWC/FLW (including ASHA Supervisor) and Stop TB by + // user+block (Stop TB's "block" is the Nikshay TU — see blockIDToUse + // in updateGroupedWorkLocation) so multiple roles for the same + // user+location show as one row with multiple role chips, instead of + // a separate row per role. Other services still get one row per entry. const isFacilityService = - row.serviceName === 'HWC' || row.serviceName === 'FLW'; + row.serviceName === 'HWC' || + row.serviceName === 'FLW' || + row.serviceName === 'Stop TB'; let key: string; if (isFacilityService) { key = [ @@ -2302,6 +2577,9 @@ export class WorkLocationMappingComponent Inbound: 'N/A', Outbound: 'N/A', teleConsultation: [null], + // Not part of the mapping request; assigned after Save (AMM-2496). + stopTBTeamVanID: this.selectedStopTBTeamID, + stopTBTeamName: this.stopTBTeamName, }; this.bufferArray.data.push(stopTBWorkLocationObj); if (this.paginatorSecond) { @@ -2542,6 +2820,9 @@ export class WorkLocationMappingComponent nikshayFacilityID: this.bufferArray.data[i].nikshayFacilityID || null, + + // Stop TB team (van), AMM-2496 + vanID: this.bufferArray.data[i].stopTBTeamVanID ?? null, }, ], @@ -2633,7 +2914,15 @@ export class WorkLocationMappingComponent .SaveWorkLocationMapping(requestArray) .pipe(takeUntil(this.destroy$)) .subscribe( - () => { + (response: any) => { + const failure = this.apiFailure(response); + if (failure) { + this.alertService.alert( + 'Failed to save work location mapping: ' + failure, + 'error', + ); + return; + } this.alertService.alert('Mapping saved successfully', 'success'); this.cleanupAfterSave(); }, @@ -5321,10 +5610,12 @@ export class WorkLocationMappingComponent ...new Set( group.roles .filter((r) => !r.userServciceRoleDeleted) - .map((r) => r.roleID), + .map((r) => Number(r.roleID)), ), ]; - const newRoleIDs: number[] = this.roleIDs_duringEdit; + const newRoleIDs: number[] = (this.roleIDs_duringEdit || []).map( + (rid: any) => Number(rid), + ); const rolesToAdd = newRoleIDs.filter( (rid) => !existingRoleIDs.includes(rid), @@ -5408,51 +5699,60 @@ export class WorkLocationMappingComponent ? nikshayFacilityIDArr.join(',') : null; + // Shared by "keep" (role stays checked) and "reactivate" (role was + // unchecked before, is checked again now) — both are an in-place + // update of an existing uSRMappingID, refreshed to the edit form's + // current location/role fields. + const buildRoleUpdateObj = (rid: number, uSRMappingID: any) => { + const roleName = this.getRoleNameById(rid); + const updateObj: any = { + uSRMappingID, + userID: this.userID_duringEdit, + roleID: rid, + providerServiceMapID: this.providerServiceMapID_duringEdit, + workingLocationID: this.isFacilityServicelineEdit + ? null + : this.workLocationID_duringEdit, + stateID: this.stateID_duringEdit, + districtID: this.district_duringEdit, + blockID: blockIDToUse, + blockName: blockNameToUse, + villageID: villageIDToUse, + villageName: villageNameToUse, + modifiedBy: this.createdBy, + }; + if (this.isStopTBServicelineEdit) { + updateObj.nikshayTUID = nikshayTUIDToSend; + updateObj.nikshayFacilityID = nikshayFacilityIDToSend; + updateObj.vanID = this.selectedStopTBTeamID ?? null; + } + if (group.serviceName === '1097') { + updateObj.inbound = + roleName?.toLowerCase() === 'supervisor' ? false : this.isInboundEdit; + updateObj.outbound = + roleName?.toLowerCase() === 'supervisor' + ? false + : this.isOutboundEdit; + } + if (group.serviceName === 'HWC') { + updateObj.teleConsultation = this.teleConsultationEdit; + } + if (this.isFacilityServicelineEdit && this.editFacilityMappingData) { + updateObj.facilityID = this.editFacilityMappingData.facilityID; + } + return updateObj; + }; + // UPDATE existing rows for kept roles for (const rid of rolesToKeep) { const existingEntry = group.roles.find( (r) => r.roleID === rid && !r.userServciceRoleDeleted, ); if (existingEntry) { - const roleName = this.getRoleNameById(rid); - const updateObj: any = { - uSRMappingID: existingEntry.uSRMappingID, - userID: this.userID_duringEdit, - roleID: rid, - providerServiceMapID: this.providerServiceMapID_duringEdit, - workingLocationID: this.isFacilityServicelineEdit - ? null - : this.workLocationID_duringEdit, - stateID: this.stateID_duringEdit, - districtID: this.district_duringEdit, - blockID: blockIDToUse, - blockName: blockNameToUse, - villageID: villageIDToUse, - villageName: villageNameToUse, - modifiedBy: this.createdBy, - }; - if (this.isStopTBServicelineEdit) { - updateObj.nikshayTUID = nikshayTUIDToSend; - updateObj.nikshayFacilityID = nikshayFacilityIDToSend; - } - if (group.serviceName === '1097') { - updateObj.inbound = - roleName?.toLowerCase() === 'supervisor' - ? false - : this.isInboundEdit; - updateObj.outbound = - roleName?.toLowerCase() === 'supervisor' - ? false - : this.isOutboundEdit; - } - if (group.serviceName === 'HWC') { - updateObj.teleConsultation = this.teleConsultationEdit; - } - if (this.isFacilityServicelineEdit && this.editFacilityMappingData) { - updateObj.facilityID = this.editFacilityMappingData.facilityID; - } allRequests.push( - this.worklocationmapping.UpdateWorkLocationMapping(updateObj), + this.worklocationmapping.UpdateWorkLocationMapping( + buildRoleUpdateObj(rid, existingEntry.uSRMappingID), + ), ); } } @@ -5475,14 +5775,61 @@ export class WorkLocationMappingComponent } } - // CREATE new rows for added roles - for (const rid of rolesToAdd) { - const roleName = this.getRoleNameById(rid); + // A checked role with no active row may still have an old soft-deleted + // row from a previous remove (group.roles keeps deleted entries too — + // only existingRoleIDs filters them out). Reactivate that row in place + // (same {uSRMappingID, deleted:false} call the Activate button already + // uses) instead of creating a duplicate — otherwise every remove-then- + // re-add cycle leaves another dead row behind for the same role. + const rolesToReactivate = rolesToAdd.filter((rid) => + group.roles.some((r) => r.roleID === rid && r.userServciceRoleDeleted), + ); + const rolesToCreateFresh = rolesToAdd.filter( + (rid) => !rolesToReactivate.includes(rid), + ); + + for (const rid of rolesToReactivate) { + const deadEntry = group.roles.find( + (r) => r.roleID === rid && r.userServciceRoleDeleted, + ); + if (deadEntry) { + const reactivateObj = { + uSRMappingID: deadEntry.uSRMappingID, + deleted: false, + }; + allRequests.push( + group.serviceID === 4 + ? this.worklocationmapping.DeleteWorkLocationMappingForTM( + reactivateObj, + ) + : this.worklocationmapping.DeleteWorkLocationMapping(reactivateObj), + ); + // Reactivating only flips the deleted flag — refresh its location/ + // role fields to what the edit form currently shows, same as a kept role. + allRequests.push( + this.worklocationmapping.UpdateWorkLocationMapping( + buildRoleUpdateObj(rid, deadEntry.uSRMappingID), + ), + ); + } + } + + // CREATE new rows for roles that never existed for this user+scope. + // Batched into a single SaveWorkLocationMapping call (one previleges + // entry, ID array holding every added role) instead of one call per + // role. Firing them separately let the backend treat each create as + // the sole active role for this user+serviceline+block, so parallel + // creates raced and only the last commit stayed active — dropping + // roles added alongside others (reproduced with StopTB Counsellor/ + // Nurse). Location fields (incl. StopTB's nikshayTUID/nikshayFacilityID) + // are per-group, not per-role, so batching them here is unchanged. + if (rolesToCreateFresh.length > 0) { const newObj: any = { previleges: [ { - ID: [ - { + ID: rolesToCreateFresh.map((rid) => { + const roleName = this.getRoleNameById(rid); + return { roleID: rid, teleConsultation: group.serviceName === 'HWC' && @@ -5500,8 +5847,8 @@ export class WorkLocationMappingComponent roleName?.toLowerCase() !== 'supervisor' ? this.isOutboundEdit : null, - }, - ], + }; + }), providerServiceMapID: this.providerServiceMapID_duringEdit, workingLocationID: this.isFacilityServicelineEdit ? null @@ -5520,6 +5867,7 @@ export class WorkLocationMappingComponent ? { nikshayTUID: nikshayTUIDToSend, nikshayFacilityID: nikshayFacilityIDToSend, + vanID: this.selectedStopTBTeamID ?? null, } : {}), }, @@ -5541,7 +5889,16 @@ export class WorkLocationMappingComponent forkJoin(allRequests) .pipe(takeUntil(this.destroy$)) .subscribe( - () => { + (responses: any) => { + const failure = this.apiFailure(responses); + if (failure) { + this.alertService.alert( + 'Failed to update mapping: ' + failure, + 'error', + ); + this.getAllMappedWorkLocations(); + return; + } this.alertService.alert('Mapping updated successfully', 'success'); this.showTable(); this.getAllMappedWorkLocations(); diff --git a/src/app/app-provider-admin/provider-admin/inventory/item-master/edit-item-master.html b/src/app/app-provider-admin/provider-admin/inventory/item-master/edit-item-master.html index cb758b46..74880f96 100644 --- a/src/app/app-provider-admin/provider-admin/inventory/item-master/edit-item-master.html +++ b/src/app/app-provider-admin/provider-admin/inventory/item-master/edit-item-master.html @@ -161,6 +161,12 @@ +
+ + Min Stock (low stock alert) + + +
diff --git a/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.html b/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.html index d7ba0c28..22c48d05 100644 --- a/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.html +++ b/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.html @@ -403,6 +403,18 @@

+
+ + Min Stock (low stock alert) + + +
@@ -841,6 +853,18 @@

/>

+
+ + Min Stock (low stock alert) + + +
diff --git a/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.ts b/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.ts index acf6c945..0bd28d42 100644 --- a/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.ts +++ b/src/app/app-provider-admin/provider-admin/inventory/item-master/item-master.component.ts @@ -102,6 +102,7 @@ export class ItemMasterComponent implements OnInit { edit_Uom: any; edit_DrugType: any; edit_Composition: any; + edit_MinStock: any; edit_Route: any; edit_Description: any; itemID: any; @@ -117,6 +118,7 @@ export class ItemMasterComponent implements OnInit { code: any; name: any; composition: any; + minStock: any; description: any; route: any; strength: any; @@ -490,6 +492,7 @@ export class ItemMasterComponent implements OnInit { this.itemCreationForm.controls['manufacturer'].reset(); this.itemCreationForm.controls['drugType'].reset(); this.itemCreationForm.controls['composition'].reset(); + this.itemCreationForm.controls['minStock']?.reset(); this.itemCreationForm.controls['route'].reset(); this.itemCreationForm.controls['description'].reset(); this.drugType = false; @@ -541,6 +544,7 @@ export class ItemMasterComponent implements OnInit { uomID: formValue.uom.uOMID, isScheduledDrug: formValue.drugType, composition: formValue.composition, + minStock: this.toStock(formValue.minStock), routeID: formValue.route.routeID, createdBy: this.createdBy, providerServiceMapID: this.providerServiceMapID, @@ -586,6 +590,7 @@ export class ItemMasterComponent implements OnInit { uomID: formValue.uom.uOMID, isScheduledDrug: formValue.drugType, composition: formValue.composition, + minStock: this.toStock(formValue.minStock), routeID: formValue.route.routeID, createdBy: this.createdBy, providerServiceMapID: this.providerServiceMapID, @@ -701,12 +706,25 @@ export class ItemMasterComponent implements OnInit { this.edit_Uom = itemlist.uomID; this.edit_DrugType = itemlist.isScheduledDrug; this.edit_Composition = itemlist.composition; + this.edit_MinStock = itemlist.minStock; this.edit_Route = itemlist.routeID; this.edit_Description = itemlist.itemDesc; if (itemlist.isEDL === true) this.editDrug = 'EDL'; else this.editDrug = 'Non-EDL'; this.showEditForm(); } + // Min Stock: whole number >= 0, or null when left empty + toStock(value: any) { + const n = Number(value); + return value === null || + value === undefined || + value === '' || + isNaN(n) || + n < 0 + ? null + : Math.floor(n); + } + showEditForm() { this.tableMode = false; this.showFormFlag = false; @@ -743,6 +761,7 @@ export class ItemMasterComponent implements OnInit { providerServiceMapID: this.providerServiceMapID, modifiedBy: this.createdBy, itemID: this.itemID, + minStock: this.toStock(editItemCreationForm.minStock), sctCode: this.editSnomedCode, sctTerm: this.editSnomedName, }; @@ -897,6 +916,7 @@ export class EditItemMasterModalComponent implements OnInit { uom: any; drugType: any; composition: any; + minStock: any; route: any; description: any; @@ -1033,6 +1053,7 @@ export class EditItemMasterModalComponent implements OnInit { this.uom = this.data.uomID; this.drugType = this.data.isScheduledDrug; this.composition = this.data.composition; + this.minStock = this.data.minStock; this.route = this.data.routeID; this.description = this.data.itemDesc; } @@ -1052,6 +1073,12 @@ export class EditItemMasterModalComponent implements OnInit { uomID: this.uom, isScheduledDrug: this.drugType, composition: this.composition, + minStock: + this.minStock === null || + this.minStock === undefined || + this.minStock === '' + ? null + : Math.max(0, Math.floor(Number(this.minStock))), routeID: this.route, status: 'active', providerServiceMapID: this.data.providerServiceMapID, diff --git a/src/assets/bulkuser_excel_sheet.xlsx b/src/assets/bulkuser_excel_sheet.xlsx index d253dbd6..b6a2c43a 100644 Binary files a/src/assets/bulkuser_excel_sheet.xlsx and b/src/assets/bulkuser_excel_sheet.xlsx differ diff --git a/src/environments/environment.ci.ts.template b/src/environments/environment.ci.ts.template index 612d8c61..5acb9fbf 100644 --- a/src/environments/environment.ci.ts.template +++ b/src/environments/environment.ci.ts.template @@ -171,6 +171,10 @@ export const environment = { nikshayFacilities_url: `${adminBaseUrl}nikshay/location/facilities`, nikshayVillages_url: `${adminBaseUrl}nikshay/location/villages`, nikshayUserMapping_url: `${adminBaseUrl}nikshay/location/userMapping`, + // Stop TB teams (MMU vans), AMM-2496 + stopTBTeams_url: `${adminBaseUrl}stoptb/team/vans`, + stopTBUserTeam_url: `${adminBaseUrl}stoptb/team/user`, + stopTBTeamMembers_url: `${adminBaseUrl}stoptb/team/members`, update_facilities_Url: `${adminBaseUrl}editFacility`, delete_facilities_Url: `${adminBaseUrl}deleteFacility`, get_FacilityLevels_Url: `${adminBaseUrl}getFacilityLevels`, diff --git a/src/environments/environment.local.ts b/src/environments/environment.local.ts index 668d2f0d..e72fe9f4 100644 --- a/src/environments/environment.local.ts +++ b/src/environments/environment.local.ts @@ -171,6 +171,10 @@ export const environment = { nikshayFacilities_url: `${adminBaseUrl}nikshay/location/facilities`, nikshayVillages_url: `${adminBaseUrl}nikshay/location/villages`, nikshayUserMapping_url: `${adminBaseUrl}nikshay/location/userMapping`, + // Stop TB teams (MMU vans), AMM-2496 + stopTBTeams_url: `${adminBaseUrl}stoptb/team/vans`, + stopTBUserTeam_url: `${adminBaseUrl}stoptb/team/user`, + stopTBTeamMembers_url: `${adminBaseUrl}stoptb/team/members`, update_facilities_Url: `${adminBaseUrl}editFacility`, delete_facilities_Url: `${adminBaseUrl}deleteFacility`, get_FacilityLevels_Url: `${adminBaseUrl}getFacilityLevels`, diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 273283a2..c698fcd8 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -169,6 +169,10 @@ export const environment = { nikshayFacilities_url: `${adminBaseUrl}nikshay/location/facilities`, nikshayVillages_url: `${adminBaseUrl}nikshay/location/villages`, nikshayUserMapping_url: `${adminBaseUrl}nikshay/location/userMapping`, + // Stop TB teams (MMU vans), AMM-2496 + stopTBTeams_url: `${adminBaseUrl}stoptb/team/vans`, + stopTBUserTeam_url: `${adminBaseUrl}stoptb/team/user`, + stopTBTeamMembers_url: `${adminBaseUrl}stoptb/team/members`, update_facilities_Url: `${adminBaseUrl}editFacility`, delete_facilities_Url: `${adminBaseUrl}deleteFacility`, get_FacilityLevels_Url: `${adminBaseUrl}getFacilityLevels`, diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 007064b5..12f49f25 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -165,6 +165,10 @@ export const environment = { nikshayFacilities_url: `${adminBaseUrl}nikshay/location/facilities`, nikshayVillages_url: `${adminBaseUrl}nikshay/location/villages`, nikshayUserMapping_url: `${adminBaseUrl}nikshay/location/userMapping`, + // Stop TB teams (MMU vans), AMM-2496 + stopTBTeams_url: `${adminBaseUrl}stoptb/team/vans`, + stopTBUserTeam_url: `${adminBaseUrl}stoptb/team/user`, + stopTBTeamMembers_url: `${adminBaseUrl}stoptb/team/members`, update_facilities_Url: `${adminBaseUrl}editFacility`, delete_facilities_Url: `${adminBaseUrl}deleteFacility`, get_FacilityLevels_Url: `${adminBaseUrl}getFacilityLevels`,