diff --git a/src/app/app-modules/supervisor/reports/call-type-report.component.ts b/src/app/app-modules/supervisor/reports/call-type-report.component.ts new file mode 100644 index 0000000..0509e85 --- /dev/null +++ b/src/app/app-modules/supervisor/reports/call-type-report.component.ts @@ -0,0 +1,280 @@ +/* + * 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, + OnInit, + 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 { + clampReportEnd, + maxReportDay, + ReportsApiService, + reportDatesValidator, + saveBlob, +} from './reports-api.service'; +import { dayBoundary, localDate } from '../allocation/allocation-api.service'; +import { BeneficiaryApiService } from '@/app-modules/core/services/beneficiary-api.service'; +import { LocationApiService } from '@/app-modules/core/services/location-api.service'; +import { NotificationService } from '@/app-modules/core/services/notification.service'; +import { CallType, CallTypeGroup, DistrictRow, RegistrationData } from '@/app-modules/core/models'; +import { SessionStore } from '@/app-modules/core/state/session.store'; + +/** + * Call Type Report (supervisor case 9): call type/sub-type cascade + demographic filters → + * server-side XLSX blob. Same yesterday-cap/31-day window as the distribution reports; the + * old payload sent day-boundary strings with a `.999Z` end. + */ +@Component({ + selector: 'app-call-type-report', + imports: [ReactiveFormsModule, ZardButtonComponent, ZardInputDirective, ...ZardSelectImports], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Call Type Report

+
+ + + + + + + + + +
+ +
+
+
+ `, +}) +export class CallTypeReportComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly api = inject(ReportsApiService); + private readonly beneficiaryApi = inject(BeneficiaryApiService); + private readonly locationApi = inject(LocationApiService); + private readonly notify = inject(NotificationService); + private readonly sessionStore = inject(SessionStore); + + private readonly callTypeGroups = signal([]); + protected readonly callGroups = computed(() => + this.callTypeGroups() + .map((g) => g.callGroupType ?? '') + .filter(Boolean), + ); + protected readonly subTypes = signal([]); + protected readonly states = signal>([]); + protected readonly districts = signal([]); + protected readonly genders = signal>([]); + protected readonly languages = signal>([]); + protected readonly orientations = signal>([]); + protected readonly downloading = signal(false); + + protected readonly maxDay = maxReportDay(); + protected readonly maxEndDay = signal(this.maxDay); + + protected readonly form = this.fb.group( + { + startDate: this.fb.control(null, Validators.required), + endDate: this.fb.control(null, Validators.required), + callType: this.fb.control(null), + callSubType: this.fb.control(null), + state: this.fb.control(null), + district: this.fb.control(null), + gender: this.fb.control(null), + language: this.fb.control(null), + sexuality: this.fb.control(null), + }, + { validators: reportDatesValidator(this.maxDay, () => this.maxEndDay()) }, + ); + + ngOnInit(): void { + const serviceId = this.sessionStore.currentServiceId(); + if (serviceId == null) { + return; + } + this.api.getCallTypes(serviceId).subscribe({ + next: (res) => this.callTypeGroups.set(res?.data ?? []), + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load call types', 'error'), + }); + this.beneficiaryApi.getRegistrationData(serviceId).subscribe({ + next: (res) => { + if (res?.data) { + this.states.set(res.data.states ?? []); + this.genders.set(res.data.m_genders ?? []); + this.languages.set(res.data.m_language ?? []); + this.orientations.set(res.data.sexualOrientations ?? []); + } + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load filters', 'error'), + }); + } + + protected onStartDateChange(): void { + const start = this.form.controls.startDate.value; + if (!start) { + return; + } + const end = clampReportEnd(start); + this.maxEndDay.set(end); + this.form.patchValue({ endDate: end }); + } + + // Handlers take the emitted value: z-select fires zValueChange BEFORE its CVA writes the + // form control, so reading the control here would see the previous selection. + protected onCallTypeChange(value: string | string[]): void { + // Old getCallSubType never cleared the chosen sub-type — a stale one stays in the payload. + this.subTypes.set( + this.callTypeGroups().find((g) => g.callGroupType === (value as string))?.callTypes ?? [], + ); + } + + protected onStateChange(value: string | string[]): void { + this.districts.set([]); + this.form.patchValue({ district: null }); + const state = this.states().find((s) => s.stateName === (value as string)); + if (state?.stateID == null) { + return; + } + this.locationApi.getDistricts(state.stateID).subscribe({ + next: (res) => this.districts.set(res?.data ?? []), + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load districts', 'error'), + }); + } + + protected download(): void { + const v = this.form.getRawValue(); + if (!v.startDate || !v.endDate) { + return; + } + const body: Record = { + providerServiceMapID: this.sessionStore.currentServiceId(), + beneficiaryCallType: v.callType ?? undefined, + beneficiaryCallSubType: v.callSubType ?? undefined, + startTimestamp: dayBoundary(localDate(v.startDate), 'start'), + // Old call-type report ended the window at .999 (unlike the distribution reports). + endTimestamp: dayBoundary(localDate(v.endDate), 'end999'), + state: v.state ?? undefined, + district: v.district ?? undefined, + gender: v.gender ?? undefined, + beneficiaryPreferredLanguage: v.language ?? undefined, + beneficiarySexualOrientation: v.sexuality ?? undefined, + fileName: 'Call_Type_Report', + }; + this.downloading.set(true); + this.api.downloadReport('crmReports/getAllReportsByDate', body).subscribe({ + next: (blob) => { + this.downloading.set(false); + if (blob) { + saveBlob(blob, 'Call_Type_Report.xlsx'); + this.notify.alert('Call type report downloaded', 'success'); + } else { + this.notify.alert('No data found', 'info'); + } + }, + error: (err: { status?: number }) => { + this.downloading.set(false); + if (err?.status === 500) { + this.notify.alert('No data found', 'info'); + } else { + this.notify.alert('Error while fetching report', 'error'); + } + }, + }); + } +} diff --git a/src/app/app-modules/supervisor/reports/distribution-report.component.ts b/src/app/app-modules/supervisor/reports/distribution-report.component.ts new file mode 100644 index 0000000..73b6639 --- /dev/null +++ b/src/app/app-modules/supervisor/reports/distribution-report.component.ts @@ -0,0 +1,306 @@ +/* + * 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, 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 { + clampReportEnd, + maxReportDay, + ReportsApiService, + reportDatesValidator, + saveBlob, +} from './reports-api.service'; +import { dayBoundary, localDate } from '../allocation/allocation-api.service'; +import { BeneficiaryApiService } from '@/app-modules/core/services/beneficiary-api.service'; +import { LocationApiService } from '@/app-modules/core/services/location-api.service'; +import { NotificationService } from '@/app-modules/core/services/notification.service'; +import { DistrictRow, RegistrationData } from '@/app-modules/core/models'; +import { SessionStore } from '@/app-modules/core/state/session.store'; + +export type DistributionReportKind = 'age' | 'gender' | 'language' | 'sexualOrientation'; + +interface DimensionOption { + display: string; + value: unknown; +} + +interface ReportConfig { + title: string; + dimensionLabel: string; + path: string; + fileName: string; + options: (data: RegistrationData) => DimensionOption[]; + dimension: (value: unknown) => Record; +} + +/** Old hardcoded age buckets (min/max sent to the backend; 'All' → nulls). */ +const AGE_GROUPS: DimensionOption[] = [ + { display: 'Below 15', value: { minAge: 0, maxAge: 15 } }, + { display: '15 to 24', value: { minAge: 15, maxAge: 24 } }, + { display: '25 to 39', value: { minAge: 25, maxAge: 39 } }, + { display: '40 to 59', value: { minAge: 40, maxAge: 59 } }, + { display: 'Above 59', value: { minAge: 59, maxAge: 150 } }, + { display: 'All', value: 'All' }, +]; + +const REPORTS: Record = { + age: { + title: 'Caller Age Report', + dimensionLabel: 'Age Group', + path: 'crmReports/getAllByAgeGroup', + fileName: 'Caller_Age_Group_Report', + options: () => AGE_GROUPS, + dimension: (value) => { + const group = value as { minAge?: number; maxAge?: number } | 'All'; + const all = group === 'All'; + return { + maxAge: all ? null : (group as { maxAge?: number }).maxAge, + minAge: all ? null : (group as { minAge?: number }).minAge, + callerAgeGroup: all + ? 'All' + : `${(group as { minAge?: number }).minAge} to ${(group as { maxAge?: number }).maxAge}`, + }; + }, + }, + gender: { + title: 'Gender Distribution Report', + dimensionLabel: 'Gender', + path: 'crmReports/getAllByGender', + fileName: 'Gender_Distribution_Report', + options: (d) => [ + ...(d.m_genders ?? []).map((g) => ({ display: g.genderName ?? '', value: g.genderName })), + { display: 'All', value: 'All' }, + ], + dimension: (value) => ({ gender: value === 'All' ? null : value }), + }, + language: { + title: 'Language Distribution Report', + dimensionLabel: 'Language', + path: 'crmReports/getCountsByPreferredLanguage', + fileName: 'Language_Distribution_Report', + options: (d) => [ + ...(d.m_language ?? []).map((l) => ({ display: l.languageName ?? '', value: l.languageName })), + { display: 'All', value: 'All' }, + ], + dimension: (value) => ({ beneficiaryPreferredLanguage: value === 'All' ? null : value }), + }, + sexualOrientation: { + title: 'Sexual Orientation Report', + dimensionLabel: 'Sexual Orientation', + path: 'crmReports/getAllBySexualOrientation', + fileName: 'Sexual_Orientation_Report', + options: (d) => [ + ...(d.sexualOrientations ?? []).map((s) => ({ + display: s.sexualOrientation ?? '', + value: s.sexualOrientation, + })), + { display: 'All', value: 'All' }, + ], + dimension: (value) => ({ beneficiarySexualOrientation: value === 'All' ? null : value }), + }, +}; + +/** + * The four distribution reports (supervisor cases 15–18; old app had four near-identical + * components). Server-side XLSX blob; reports run up to YESTERDAY with a ≤31-day span. + */ +@Component({ + selector: 'app-distribution-report', + imports: [ReactiveFormsModule, ZardButtonComponent, ZardInputDirective, ...ZardSelectImports], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

{{ config().title }}

+
+ + + + + + +
+
+ `, +}) +export class DistributionReportComponent implements OnInit { + private readonly fb = inject(FormBuilder); + private readonly api = inject(ReportsApiService); + private readonly beneficiaryApi = inject(BeneficiaryApiService); + private readonly locationApi = inject(LocationApiService); + private readonly notify = inject(NotificationService); + private readonly sessionStore = inject(SessionStore); + + readonly kind = input.required(); + + protected readonly config = computed(() => REPORTS[this.kind()]); + protected readonly states = signal>([]); + protected readonly districts = signal([]); + protected readonly options = signal([]); + protected readonly downloading = signal(false); + + protected readonly maxDay = maxReportDay(); + protected readonly maxEndDay = signal(this.maxDay); + + protected readonly form = this.fb.group( + { + startDate: this.fb.control(null, Validators.required), + endDate: this.fb.control(null, Validators.required), + state: this.fb.control(null), + district: this.fb.control(null), + dimension: this.fb.control(null, Validators.required), + }, + { validators: reportDatesValidator(this.maxDay, () => this.maxEndDay()) }, + ); + + ngOnInit(): void { + // Old caller-age-report hardcoded its buckets in ngOnInit, independent of any API call. + if (this.kind() === 'age') { + this.options.set(AGE_GROUPS); + } + const serviceId = this.sessionStore.currentServiceId(); + if (serviceId == null) { + return; + } + this.beneficiaryApi.getRegistrationData(serviceId).subscribe({ + next: (res) => { + if (res?.data) { + this.states.set(res.data.states ?? []); + this.options.set(this.config().options(res.data)); + } + }, + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load filters', 'error'), + }); + } + + protected onStartDateChange(): void { + const start = this.form.controls.startDate.value; + if (!start) { + return; + } + const end = clampReportEnd(start); + this.maxEndDay.set(end); + this.form.patchValue({ endDate: end }); + } + + // Takes 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 onStateChange(value: string | string[]): void { + this.districts.set([]); + this.form.patchValue({ district: null }); + const state = this.states().find((s) => s.stateName === (value as string)); + if (state?.stateID == null) { + return; + } + this.locationApi.getDistricts(state.stateID).subscribe({ + next: (res) => this.districts.set(res?.data ?? []), + error: (err: { errorMessage?: string }) => + this.notify.alert(err?.errorMessage ?? 'Failed to load districts', 'error'), + }); + } + + protected download(): void { + const v = this.form.getRawValue(); + if (!v.startDate || !v.endDate) { + return; + } + const option = this.options().find((o) => o.display === v.dimension); + const config = this.config(); + const body: Record = { + startTimestamp: dayBoundary(localDate(v.startDate), 'start'), + endTimestamp: dayBoundary(localDate(v.endDate), 'end'), + providerServiceMapID: this.sessionStore.currentServiceId(), + ...config.dimension(option?.value), + state: v.state ?? undefined, + district: v.district || undefined, + fileName: config.fileName, + }; + this.downloading.set(true); + this.api.downloadReport(config.path, body).subscribe({ + next: (blob) => { + this.downloading.set(false); + if (blob) { + saveBlob(blob, `${config.fileName}.xlsx`); + this.notify.alert(`${config.title} downloaded`, 'success'); + } else { + this.notify.alert('No data found', 'info'); + } + }, + error: (err: { status?: number }) => { + this.downloading.set(false); + if (err?.status === 500) { + this.notify.alert('No data found', 'info'); + } else { + this.notify.alert('Error while fetching report', 'error'); + } + }, + }); + } +} diff --git a/src/app/app-modules/supervisor/reports/reports-api.service.ts b/src/app/app-modules/supervisor/reports/reports-api.service.ts new file mode 100644 index 0000000..f849498 --- /dev/null +++ b/src/app/app-modules/supervisor/reports/reports-api.service.ts @@ -0,0 +1,117 @@ +/* + * 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 { ValidatorFn } from '@angular/forms'; +import { Observable } from 'rxjs'; + +import { localDate } from '../allocation/allocation-api.service'; +import { ApiResponse, CallTypeGroup } from '@/app-modules/core/models'; +import { ConfigService } from '@/app-modules/core/services/config.service'; + +/** Trigger a browser download for a server-produced report blob (old `saveAs`). */ +export function saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + // Revoking before the download starts can abort it; FileSaver kept the URL alive ~40s. + setTimeout(() => URL.revokeObjectURL(url), 40000); +} + +/** Yesterday at local midnight — the reports' max selectable day (old `maxStartDate`). */ +export function yesterday(): Date { + const d = new Date(); + d.setDate(d.getDate() - 1); + d.setHours(0, 0, 0, 0); + return d; +} + +function toDayString(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** `yesterday()` as a yyyy-MM-dd string for native date-input `max`. */ +export function maxReportDay(): string { + return toDayString(yesterday()); +} + +/** + * Old `endDateChange`: the window ends at yesterday, capped to start+30 days when the start + * is 31+ days before yesterday. The result is both the auto-filled end date and its `max`. + */ +export function clampReportEnd(start: string): string { + const startDay = localDate(start); + const spanDays = Math.ceil((yesterday().getTime() - startDay.getTime()) / 86400000); + const end = new Date(startDay); + if (spanDays > 30) { + end.setDate(end.getDate() + 30); + } else { + end.setTime(yesterday().getTime()); + } + return toDayString(end); +} + +/** + * Native date inputs' min/max don't feed reactive-form validity (the old md2-datepicker's + * did, disabling Download on out-of-range dates) — so re-validate the window here. + */ +export function reportDatesValidator(maxDay: string, maxEnd: () => string): ValidatorFn { + return (group) => { + const start = group.get('startDate')?.value as string | null; + const end = group.get('endDate')?.value as string | null; + if (start && start > maxDay) { + return { startOutOfRange: true }; + } + if (end && (end > maxEnd() || (start != null && end < start))) { + return { endOutOfRange: true }; + } + return null; + }; +} + +/** + * `crmReports/*` XLSX downloads. The old ReportsService bypassed its interceptors with a + * construction-time token (stale-token bug); ours goes through the normal chain — the + * response interceptor passes non-envelope bodies (blobs) untouched. + */ +@Injectable({ providedIn: 'root' }) +export class ReportsApiService { + private readonly http = inject(HttpClient); + private readonly config = inject(ConfigService); + + downloadReport(path: string, body: Record): Observable { + return this.http.post(`${this.config.commonBaseURL}${path}`, body, { + responseType: 'blob', + }); + } + + /** POST call/getCallTypesV1 with the report's plain body (no campaign flags). */ + getCallTypes(providerServiceMapID: number): Observable> { + return this.http.post>( + `${this.config.commonBaseURL}call/getCallTypesV1`, + { providerServiceMapID }, + ); + } +} diff --git a/src/app/app-modules/supervisor/supervisor-shell.component.html b/src/app/app-modules/supervisor/supervisor-shell.component.html index b81f1e1..0144a6e 100644 --- a/src/app/app-modules/supervisor/supervisor-shell.component.html +++ b/src/app/app-modules/supervisor/supervisor-shell.component.html @@ -80,9 +80,24 @@ @case ('14') { } + @case ('15') { + + } + @case ('16') { + + } + @case ('17') { + + } + @case ('18') { + + } @case ('7') { } + @case ('9') { + + } @case ('12') { } diff --git a/src/app/app-modules/supervisor/supervisor-shell.component.ts b/src/app/app-modules/supervisor/supervisor-shell.component.ts index c09ea7a..8da2131 100644 --- a/src/app/app-modules/supervisor/supervisor-shell.component.ts +++ b/src/app/app-modules/supervisor/supervisor-shell.component.ts @@ -30,6 +30,8 @@ import { menuImports } from '@common-ui/ui/menu'; import { CallAllocationComponent } from './allocation/call-allocation.component'; import { CallReallocationComponent } from './allocation/call-reallocation.component'; +import { CallTypeReportComponent } from './reports/call-type-report.component'; +import { DistributionReportComponent } from './reports/distribution-report.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,15 +58,15 @@ const PAGES: Record = { '6': { label: 'Supervisor Notifications', arrives: 'a later phase (menu-less in the old app)' }, '7': { label: 'Telephony Reports' }, '8': { label: 'Supervisor Configurations', arrives: 'a later phase (menu-less in the old app)' }, - '9': { label: 'Call Type Report', arrives: 'Phase 7e' }, + '9': { label: 'Call Type Report' }, '10': { label: 'Knowledge Management', arrives: 'Phase 7g' }, '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' }, - '17': { label: 'Language Distribution Report', arrives: 'Phase 7e' }, - '18': { label: 'Gender Distribution Report', arrives: 'Phase 7e' }, + '15': { label: 'Caller Age Report' }, + '16': { label: 'Sexual Orientation Report' }, + '17': { label: 'Language Distribution Report' }, + '18': { label: 'Gender Distribution Report' }, '19': { label: 'Alerts and Notifications', arrives: 'Phase 7f' }, '20': { label: 'Location Messages', arrives: 'Phase 7f' }, '21': { label: 'Training Resource', arrives: 'Phase 7f' }, @@ -147,6 +149,8 @@ const CONFIGURATIONS_MENU: MenuEntry[] = [ TelephonyIframeComponent, CallAllocationComponent, CallReallocationComponent, + CallTypeReportComponent, + DistributionReportComponent, ...menuImports, ], changeDetection: ChangeDetectionStrategy.OnPush,