From a14b816652e42554a0b3e31c4409235404f847df Mon Sep 17 00:00:00 2001 From: Sneha Date: Fri, 14 Aug 2026 14:18:40 +0530 Subject: [PATCH 1/3] feat(stoptb): add Nikshay results CSV upload tab to camp hub dialog Adds the upload half of the Nikshay ID Generator round trip: a new tab lets camp staff pick the visit date and the app's results CSV, posts it to MMU-API's importResultsCsv endpoint, and shows how many Nikshay IDs were written back versus how many rows need manual review or failed, with beneficiary names so staff know who to check. Co-Authored-By: Claude Sonnet 5 --- .../camp-hub-qr-code.component.css | 17 + .../camp-hub-qr-code.component.html | 295 ++++++++++++++---- .../camp-hub-qr-code.component.ts | 241 +++++++++++++- src/environments/environment.ci.ts.template | 2 + src/environments/environment.dev.ts | 2 + src/environments/environment.local.ts | 2 + src/environments/environment.prod.ts | 2 + src/environments/environment.test.ts | 2 + 8 files changed, 493 insertions(+), 70 deletions(-) diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css index c1b5fc20..173cf83c 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css @@ -45,6 +45,23 @@ min-width: 420px; } +.nikshay-tab-content { + padding-top: 12px; +} + +.camp-info-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #e3f2fd; + border-radius: 20px; + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + color: #0b69b2; + margin-bottom: 8px; +} + .qr-subtitle { font-size: 13px; color: #555; diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html index ce918604..14610687 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html @@ -28,72 +28,231 @@ -

Scan with the Stop TB Mobile app to connect over Wi-Fi.

- - -
- - Fetching Camp Hub server info… -
- -
- info - - Camp Hub server unreachable — enter the server URL manually below. - - -
- -
- check_circle - Server URL fetched — review below and click Generate. -
- - -
- - Camp Hub Server URL - - Format: http://<network-ip>:<port>/ - - URL is required - - - Must start with http:// or https:// - - - -
- -
-
- - -
-
- wifi - {{ generatedUrl }} -
- - Camp Hub QR Code - - -
+ + +
+

Scan with the Stop TB Mobile app to connect over Wi-Fi.

+ + +
+ + Fetching Camp Hub server info… +
+ +
+ info + + Camp Hub server unreachable — enter the server URL manually below. + + +
+ +
+ check_circle + Server URL fetched — review below and click Generate. +
+ + +
+ + Camp Hub Server URL + + Format: http://<network-ip>:<port>/ + + URL is required + + + Must start with http:// or https:// + + + +
+ +
+
+ + +
+
+ wifi + {{ generatedUrl }} +
+ + Camp Hub QR Code + + +
+
+
+ + +
+

+ Download this camp's beneficiaries as a CSV formatted for the Nikshay ID Generator. +

+ +
+ local_shipping + {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} +
+
+ info + + No active camp session found — select a van and service point first. + +
+ +
+ + Camp Date Range + + + + + MM/DD/YYYY – MM/DD/YYYY + + + + Both dates are required + + + +
+ +
+
+
+
+ + +
+

+ Upload the Nikshay ID Generator app's results CSV to write the generated Nikshay IDs + back onto this camp's beneficiaries. +

+ +
+ local_shipping + {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} +
+
+ info + + No active camp session found — select a van and service point first. + +
+ +
+ + Visit Date + + + + The camp date these beneficiaries were screened on. + + Visit date is required + + + +
+ + + +
+ +
+ +
+
+ +
+

+ {{ lastImportSummary.updated }} Nikshay ID(s) updated · + {{ lastImportSummary.needsReview }} row(s) need review · + {{ lastImportSummary.failed }} failed + (of {{ lastImportSummary.csvRowCount }} row(s) in the file) +

+ +
+ Needs review: +
    +
  • + {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — + {{ row.note }} +
  • +
+
+ +
+ Failed: +
    +
  • + {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — + {{ row.note }} +
  • +
+
+
+
+
+
diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts index 9e1a8ab6..235e2396 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts @@ -21,17 +21,51 @@ */ import { Component, OnInit, Injector } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { FormBuilder, Validators } from '@angular/forms'; import { MatDialogRef } from '@angular/material/dialog'; +import { SessionStorageService } from 'Common-UI/src/registrar/services/session-storage.service'; +import { saveAs } from 'file-saver'; import * as QRCode from 'qrcode'; import { environment } from 'src/environments/environment'; +import { ConfirmationService } from '../../core/services/confirmation.service'; interface ConnectInfo { ip: string; port: 8080; } +interface CampInfo { + vanID: string; + vanLabel: string; + servicePointID: string; + servicePointName: string; +} + +interface ImportRowResult { + rowIndex: number; + benRegId: number | null; + firstName: string; + middleLastName: string; + status: string; + generatedId: string; + note: string; +} + +interface ImportSummary { + csvRowCount: number; + updated: number; + failed: number; + needsReview: number; + needsReviewRows: ImportRowResult[]; + failedRows: ImportRowResult[]; +} + +/** Keeps a single date-range download from spanning so many days that + * generating/transferring the CSV becomes slow enough to feel like it's + * hanging the browser tab. */ +const MAX_RANGE_DAYS = 90; + @Component({ selector: 'app-camp-hub-qr-code', templateUrl: './camp-hub-qr-code.component.html', @@ -51,12 +85,33 @@ export class CampHubQrCodeComponent implements OnInit { ], }); + // ── Download Beneficiaries (Nikshay CSV) tab ── + today = new Date(); + isDownloadingCsv = false; + campInfo: CampInfo | null = null; + + downloadForm = this.fb.group({ + fromDate: [null as Date | null, Validators.required], + toDate: [null as Date | null, Validators.required], + }); + + // ── Upload Nikshay Results tab ── + isUploadingCsv = false; + selectedResultsFile: File | null = null; + lastImportSummary: ImportSummary | null = null; + + uploadForm = this.fb.group({ + visitDate: [null as Date | null, Validators.required], + }); + private dialogRef: MatDialogRef | null = null; constructor( private fb: FormBuilder, private http: HttpClient, - private injector: Injector + private injector: Injector, + private sessionstorage: SessionStorageService, + private confirmationService: ConfirmationService ) { this.dialogRef = this.injector.get(MatDialogRef, null); } @@ -67,6 +122,31 @@ export class CampHubQrCodeComponent implements OnInit { ngOnInit(): void { this.autoDetect(); + this.loadCampInfo(); + } + + private loadCampInfo(): void { + try { + const serviceLine = JSON.parse( + this.sessionstorage.getItem('serviceLineDetails') ?? 'null' + ); + const servicePointID = this.sessionstorage.getItem('servicePointID'); + const servicePointName = this.sessionstorage.getItem('servicePointName'); + this.campInfo = + serviceLine?.vanID && servicePointID + ? { + vanID: serviceLine.vanID, + vanLabel: + serviceLine.vanNoAndType || + serviceLine.vehicalNo || + `Van ${serviceLine.vanID}`, + servicePointID, + servicePointName: servicePointName || `#${servicePointID}`, + } + : null; + } catch { + this.campInfo = null; + } } autoDetect(): void { @@ -116,4 +196,161 @@ export class CampHubQrCodeComponent implements OnInit { link.href = this.qrDataUrl; link.click(); } + + downloadBeneficiariesCsv(): void { + if (this.downloadForm.invalid) { + this.downloadForm.markAllAsTouched(); + return; + } + if (!this.campInfo) { + this.confirmationService.alert( + 'No active camp session found — select a van and service point first.', + 'error' + ); + return; + } + + const fromDate = this.downloadForm.value.fromDate as Date; + const toDate = this.downloadForm.value.toDate as Date; + const rangeDays = + Math.round( + (toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24) + ) + 1; + + if (rangeDays < 1) { + this.confirmationService.alert( + 'The "From" date must be on or before the "To" date.', + 'error' + ); + return; + } + if (rangeDays > MAX_RANGE_DAYS) { + this.confirmationService.alert( + `Please choose a range of ${MAX_RANGE_DAYS} days or fewer so the download stays quick.`, + 'warning' + ); + return; + } + + const params = { + fromDate: this.formatDate(fromDate), + toDate: this.formatDate(toDate), + vanID: String(this.campInfo.vanID), + servicePointID: String(this.campInfo.servicePointID), + }; + + this.isDownloadingCsv = true; + this.http + .get(environment.nikshayBeneficiaryCsvUrl, { + params, + responseType: 'blob', + observe: 'response', + }) + .subscribe({ + next: (res: HttpResponse) => { + this.isDownloadingCsv = false; + const body = res.body; + if (!body || body.size === 0) { + this.confirmationService.alert( + 'No beneficiaries found for the selected date range.', + 'info' + ); + return; + } + const filename = this.extractFilename( + res.headers.get('content-disposition'), + `nikshay-beneficiaries-${params.fromDate}-to-${params.toDate}.csv` + ); + saveAs(body, filename); + }, + error: () => { + this.isDownloadingCsv = false; + this.confirmationService.alert( + 'Could not download the beneficiary CSV. Please try again.', + 'error' + ); + }, + }); + } + + onResultsFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + this.selectedResultsFile = input.files?.[0] ?? null; + this.lastImportSummary = null; + } + + uploadResultsCsv(): void { + if (this.uploadForm.invalid) { + this.uploadForm.markAllAsTouched(); + return; + } + if (!this.campInfo) { + this.confirmationService.alert( + 'No active camp session found — select a van and service point first.', + 'error' + ); + return; + } + if (!this.selectedResultsFile) { + this.confirmationService.alert( + 'Please choose the Nikshay results CSV file to upload.', + 'error' + ); + return; + } + + const visitDate = this.uploadForm.value.visitDate as Date; + const formData = new FormData(); + formData.append('file', this.selectedResultsFile); + + const params = { + vanID: String(this.campInfo.vanID), + servicePointID: String(this.campInfo.servicePointID), + visitDate: this.formatDate(visitDate), + }; + + this.isUploadingCsv = true; + this.lastImportSummary = null; + this.http + .post(environment.nikshayImportResultsCsvUrl, formData, { + params, + }) + .subscribe({ + next: summary => { + this.isUploadingCsv = false; + this.lastImportSummary = summary; + const needsAttention = summary.failed > 0 || summary.needsReview > 0; + this.confirmationService.alert( + `Import complete — ${summary.updated} Nikshay ID(s) updated, ` + + `${summary.needsReview} row(s) need review, ${summary.failed} failed.`, + needsAttention ? 'warning' : 'info' + ); + }, + error: err => { + this.isUploadingCsv = false; + const message = + typeof err?.error === 'string' + ? err.error + : 'Could not import the results CSV. Please try again.'; + this.confirmationService.alert(message, 'error'); + }, + }); + } + + private formatDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + private extractFilename( + contentDisposition: string | null, + fallback: string + ): string { + const match = contentDisposition + ? /filename="?([^";]+)"?/i.exec(contentDisposition) + : null; + return match ? match[1] : fallback; + } } diff --git a/src/environments/environment.ci.ts.template b/src/environments/environment.ci.ts.template index c1de5e62..de26b3ed 100644 --- a/src/environments/environment.ci.ts.template +++ b/src/environments/environment.ci.ts.template @@ -510,6 +510,8 @@ export const environment = { checkUsersignExistUrl: `${ADMIN_API}signature1/signexist/`, isEnableES: false, campHubConnectInfoAPI: `${COMMON_API}public/connect/info`, + nikshayBeneficiaryCsvUrl: `${MMU_API}stopTb/nikshay/exportBeneficiariesCsv`, + nikshayImportResultsCsvUrl: `${MMU_API}stopTb/nikshay/importResultsCsv`, elasticSearchUrl: `${MMU_API}registrar/quickSearchES`, advanceElasticSearchUrl: `${MMU_API}registrar/advancedSearchES`, diff --git a/src/environments/environment.dev.ts b/src/environments/environment.dev.ts index fc0b3ac8..d0b509d8 100644 --- a/src/environments/environment.dev.ts +++ b/src/environments/environment.dev.ts @@ -529,4 +529,6 @@ export const environment = { advanceElasticSearchUrl: `${MMU_API}registrar/advancedSearchES`, isSMSFeatureEnabled: false, campHubConnectInfoAPI: `${COMMON_API}public/connect/info`, + nikshayBeneficiaryCsvUrl: `${MMU_API}stopTb/nikshay/exportBeneficiariesCsv`, + nikshayImportResultsCsvUrl: `${MMU_API}stopTb/nikshay/importResultsCsv`, }; diff --git a/src/environments/environment.local.ts b/src/environments/environment.local.ts index 8173d6d6..29eeb346 100644 --- a/src/environments/environment.local.ts +++ b/src/environments/environment.local.ts @@ -527,4 +527,6 @@ export const environment = { advanceElasticSearchUrl: `${MMU_API}registrar/advancedSearchES`, isSMSFeatureEnabled: false, campHubConnectInfoAPI: `${COMMON_API}public/connect/info`, + nikshayBeneficiaryCsvUrl: `${MMU_API}stopTb/nikshay/exportBeneficiariesCsv`, + nikshayImportResultsCsvUrl: `${MMU_API}stopTb/nikshay/importResultsCsv`, }; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d4bda788..7794b0a3 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -524,4 +524,6 @@ export const environment = { advanceElasticSearchUrl: `${MMU_API}registrar/advancedSearchES`, isSMSFeatureEnabled: false, campHubConnectInfoAPI: `${COMMON_API}public/connect/info`, + nikshayBeneficiaryCsvUrl: `${MMU_API}stopTb/nikshay/exportBeneficiariesCsv`, + nikshayImportResultsCsvUrl: `${MMU_API}stopTb/nikshay/importResultsCsv`, }; diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index d4bda788..7794b0a3 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -524,4 +524,6 @@ export const environment = { advanceElasticSearchUrl: `${MMU_API}registrar/advancedSearchES`, isSMSFeatureEnabled: false, campHubConnectInfoAPI: `${COMMON_API}public/connect/info`, + nikshayBeneficiaryCsvUrl: `${MMU_API}stopTb/nikshay/exportBeneficiariesCsv`, + nikshayImportResultsCsvUrl: `${MMU_API}stopTb/nikshay/importResultsCsv`, }; From 786351397cf0be38ddad5965a5e2ff6a87d6813f Mon Sep 17 00:00:00 2001 From: Sneha Date: Tue, 18 Aug 2026 16:22:53 +0530 Subject: [PATCH 2/3] feat: nikshaya id syn screens --- .../app-header/app-header.component.html | 5 + .../camp-hub-qr-code.component.css | 13 - .../camp-hub-qr-code.component.html | 153 ---------- .../camp-hub-qr-code.component.ts | 241 +--------------- .../nikshay-sync-routing.module.ts | 38 +++ .../nikshay-sync/nikshay-sync.component.css | 82 ++++++ .../nikshay-sync/nikshay-sync.component.html | 168 +++++++++++ .../nikshay-sync/nikshay-sync.component.ts | 269 ++++++++++++++++++ .../nikshay-sync/nikshay-sync.module.ts | 49 ++++ src/app/app-routing.module.ts | 8 + 10 files changed, 621 insertions(+), 405 deletions(-) create mode 100644 src/app/app-modules/nikshay-sync/nikshay-sync-routing.module.ts create mode 100644 src/app/app-modules/nikshay-sync/nikshay-sync.component.css create mode 100644 src/app/app-modules/nikshay-sync/nikshay-sync.component.html create mode 100644 src/app/app-modules/nikshay-sync/nikshay-sync.component.ts create mode 100644 src/app/app-modules/nikshay-sync/nikshay-sync.module.ts diff --git a/src/app/app-modules/core/components/app-header/app-header.component.html b/src/app/app-modules/core/components/app-header/app-header.component.html index 0104645f..ec7ab78e 100644 --- a/src/app/app-modules/core/components/app-header/app-header.component.html +++ b/src/app/app-modules/core/components/app-header/app-header.component.html @@ -159,6 +159,11 @@ {{ currentLanguageSet?.common?.report }} + diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css index 173cf83c..f7bef2d4 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.css @@ -49,19 +49,6 @@ padding-top: 12px; } -.camp-info-chip { - display: inline-flex; - align-items: center; - gap: 6px; - background: #e3f2fd; - border-radius: 20px; - padding: 6px 16px; - font-size: 13px; - font-weight: 500; - color: #0b69b2; - margin-bottom: 8px; -} - .qr-subtitle { font-size: 13px; color: #555; diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html index 14610687..f21cb3dc 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.html @@ -101,158 +101,5 @@ - - -
-

- Download this camp's beneficiaries as a CSV formatted for the Nikshay ID Generator. -

- -
- local_shipping - {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} -
-
- info - - No active camp session found — select a van and service point first. - -
- -
- - Camp Date Range - - - - - MM/DD/YYYY – MM/DD/YYYY - - - - Both dates are required - - - -
- -
-
-
-
- - -
-

- Upload the Nikshay ID Generator app's results CSV to write the generated Nikshay IDs - back onto this camp's beneficiaries. -

- -
- local_shipping - {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} -
-
- info - - No active camp session found — select a van and service point first. - -
- -
- - Visit Date - - - - The camp date these beneficiaries were screened on. - - Visit date is required - - - -
- - - -
- -
- -
-
- -
-

- {{ lastImportSummary.updated }} Nikshay ID(s) updated · - {{ lastImportSummary.needsReview }} row(s) need review · - {{ lastImportSummary.failed }} failed - (of {{ lastImportSummary.csvRowCount }} row(s) in the file) -

- -
- Needs review: -
    -
  • - {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — - {{ row.note }} -
  • -
-
- -
- Failed: -
    -
  • - {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — - {{ row.note }} -
  • -
-
-
-
-
diff --git a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts index 235e2396..9e1a8ab6 100644 --- a/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts +++ b/src/app/app-modules/data-sync/camp-hub-qr-code/camp-hub-qr-code.component.ts @@ -21,51 +21,17 @@ */ import { Component, OnInit, Injector } from '@angular/core'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { FormBuilder, Validators } from '@angular/forms'; import { MatDialogRef } from '@angular/material/dialog'; -import { SessionStorageService } from 'Common-UI/src/registrar/services/session-storage.service'; -import { saveAs } from 'file-saver'; import * as QRCode from 'qrcode'; import { environment } from 'src/environments/environment'; -import { ConfirmationService } from '../../core/services/confirmation.service'; interface ConnectInfo { ip: string; port: 8080; } -interface CampInfo { - vanID: string; - vanLabel: string; - servicePointID: string; - servicePointName: string; -} - -interface ImportRowResult { - rowIndex: number; - benRegId: number | null; - firstName: string; - middleLastName: string; - status: string; - generatedId: string; - note: string; -} - -interface ImportSummary { - csvRowCount: number; - updated: number; - failed: number; - needsReview: number; - needsReviewRows: ImportRowResult[]; - failedRows: ImportRowResult[]; -} - -/** Keeps a single date-range download from spanning so many days that - * generating/transferring the CSV becomes slow enough to feel like it's - * hanging the browser tab. */ -const MAX_RANGE_DAYS = 90; - @Component({ selector: 'app-camp-hub-qr-code', templateUrl: './camp-hub-qr-code.component.html', @@ -85,33 +51,12 @@ export class CampHubQrCodeComponent implements OnInit { ], }); - // ── Download Beneficiaries (Nikshay CSV) tab ── - today = new Date(); - isDownloadingCsv = false; - campInfo: CampInfo | null = null; - - downloadForm = this.fb.group({ - fromDate: [null as Date | null, Validators.required], - toDate: [null as Date | null, Validators.required], - }); - - // ── Upload Nikshay Results tab ── - isUploadingCsv = false; - selectedResultsFile: File | null = null; - lastImportSummary: ImportSummary | null = null; - - uploadForm = this.fb.group({ - visitDate: [null as Date | null, Validators.required], - }); - private dialogRef: MatDialogRef | null = null; constructor( private fb: FormBuilder, private http: HttpClient, - private injector: Injector, - private sessionstorage: SessionStorageService, - private confirmationService: ConfirmationService + private injector: Injector ) { this.dialogRef = this.injector.get(MatDialogRef, null); } @@ -122,31 +67,6 @@ export class CampHubQrCodeComponent implements OnInit { ngOnInit(): void { this.autoDetect(); - this.loadCampInfo(); - } - - private loadCampInfo(): void { - try { - const serviceLine = JSON.parse( - this.sessionstorage.getItem('serviceLineDetails') ?? 'null' - ); - const servicePointID = this.sessionstorage.getItem('servicePointID'); - const servicePointName = this.sessionstorage.getItem('servicePointName'); - this.campInfo = - serviceLine?.vanID && servicePointID - ? { - vanID: serviceLine.vanID, - vanLabel: - serviceLine.vanNoAndType || - serviceLine.vehicalNo || - `Van ${serviceLine.vanID}`, - servicePointID, - servicePointName: servicePointName || `#${servicePointID}`, - } - : null; - } catch { - this.campInfo = null; - } } autoDetect(): void { @@ -196,161 +116,4 @@ export class CampHubQrCodeComponent implements OnInit { link.href = this.qrDataUrl; link.click(); } - - downloadBeneficiariesCsv(): void { - if (this.downloadForm.invalid) { - this.downloadForm.markAllAsTouched(); - return; - } - if (!this.campInfo) { - this.confirmationService.alert( - 'No active camp session found — select a van and service point first.', - 'error' - ); - return; - } - - const fromDate = this.downloadForm.value.fromDate as Date; - const toDate = this.downloadForm.value.toDate as Date; - const rangeDays = - Math.round( - (toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24) - ) + 1; - - if (rangeDays < 1) { - this.confirmationService.alert( - 'The "From" date must be on or before the "To" date.', - 'error' - ); - return; - } - if (rangeDays > MAX_RANGE_DAYS) { - this.confirmationService.alert( - `Please choose a range of ${MAX_RANGE_DAYS} days or fewer so the download stays quick.`, - 'warning' - ); - return; - } - - const params = { - fromDate: this.formatDate(fromDate), - toDate: this.formatDate(toDate), - vanID: String(this.campInfo.vanID), - servicePointID: String(this.campInfo.servicePointID), - }; - - this.isDownloadingCsv = true; - this.http - .get(environment.nikshayBeneficiaryCsvUrl, { - params, - responseType: 'blob', - observe: 'response', - }) - .subscribe({ - next: (res: HttpResponse) => { - this.isDownloadingCsv = false; - const body = res.body; - if (!body || body.size === 0) { - this.confirmationService.alert( - 'No beneficiaries found for the selected date range.', - 'info' - ); - return; - } - const filename = this.extractFilename( - res.headers.get('content-disposition'), - `nikshay-beneficiaries-${params.fromDate}-to-${params.toDate}.csv` - ); - saveAs(body, filename); - }, - error: () => { - this.isDownloadingCsv = false; - this.confirmationService.alert( - 'Could not download the beneficiary CSV. Please try again.', - 'error' - ); - }, - }); - } - - onResultsFileSelected(event: Event): void { - const input = event.target as HTMLInputElement; - this.selectedResultsFile = input.files?.[0] ?? null; - this.lastImportSummary = null; - } - - uploadResultsCsv(): void { - if (this.uploadForm.invalid) { - this.uploadForm.markAllAsTouched(); - return; - } - if (!this.campInfo) { - this.confirmationService.alert( - 'No active camp session found — select a van and service point first.', - 'error' - ); - return; - } - if (!this.selectedResultsFile) { - this.confirmationService.alert( - 'Please choose the Nikshay results CSV file to upload.', - 'error' - ); - return; - } - - const visitDate = this.uploadForm.value.visitDate as Date; - const formData = new FormData(); - formData.append('file', this.selectedResultsFile); - - const params = { - vanID: String(this.campInfo.vanID), - servicePointID: String(this.campInfo.servicePointID), - visitDate: this.formatDate(visitDate), - }; - - this.isUploadingCsv = true; - this.lastImportSummary = null; - this.http - .post(environment.nikshayImportResultsCsvUrl, formData, { - params, - }) - .subscribe({ - next: summary => { - this.isUploadingCsv = false; - this.lastImportSummary = summary; - const needsAttention = summary.failed > 0 || summary.needsReview > 0; - this.confirmationService.alert( - `Import complete — ${summary.updated} Nikshay ID(s) updated, ` + - `${summary.needsReview} row(s) need review, ${summary.failed} failed.`, - needsAttention ? 'warning' : 'info' - ); - }, - error: err => { - this.isUploadingCsv = false; - const message = - typeof err?.error === 'string' - ? err.error - : 'Could not import the results CSV. Please try again.'; - this.confirmationService.alert(message, 'error'); - }, - }); - } - - private formatDate(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; - } - - private extractFilename( - contentDisposition: string | null, - fallback: string - ): string { - const match = contentDisposition - ? /filename="?([^";]+)"?/i.exec(contentDisposition) - : null; - return match ? match[1] : fallback; - } } diff --git a/src/app/app-modules/nikshay-sync/nikshay-sync-routing.module.ts b/src/app/app-modules/nikshay-sync/nikshay-sync-routing.module.ts new file mode 100644 index 00000000..5f52ac83 --- /dev/null +++ b/src/app/app-modules/nikshay-sync/nikshay-sync-routing.module.ts @@ -0,0 +1,38 @@ +/* + * 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { NikshaySyncComponent } from './nikshay-sync.component'; + +const routes: Routes = [ + { + path: '', + component: NikshaySyncComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class NikshaySyncRoutingModule {} diff --git a/src/app/app-modules/nikshay-sync/nikshay-sync.component.css b/src/app/app-modules/nikshay-sync/nikshay-sync.component.css new file mode 100644 index 00000000..796b8951 --- /dev/null +++ b/src/app/app-modules/nikshay-sync/nikshay-sync.component.css @@ -0,0 +1,82 @@ +.nikshay-sync-page { + max-width: 640px; + margin: 32px auto; + padding: 24px 32px 16px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} + +.page-title { + font-size: 20px; + font-weight: 500; + color: #01579b; + margin-bottom: 16px; +} + +.nikshay-tab-content { + padding-top: 12px; +} + +.camp-info-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #e3f2fd; + border-radius: 20px; + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + color: #0b69b2; + margin-bottom: 8px; +} + +.qr-subtitle { + font-size: 13px; + color: #555; + margin-bottom: 8px; +} + +.detect-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + color: #555; + margin-bottom: 4px; +} + +.detect-row.warn { + background: #fff8e1; + color: #6d4c00; +} + +.detect-text { + flex: 1; + line-height: 1.4; +} + +.qr-result { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 0 8px; + gap: 16px; +} + +.url-icon { + font-size: 18px; + flex-shrink: 0; +} + +.url-text { + font-family: monospace; +} + +.btn-icon { + vertical-align: middle; + font-size: 18px; + margin-right: 4px; +} diff --git a/src/app/app-modules/nikshay-sync/nikshay-sync.component.html b/src/app/app-modules/nikshay-sync/nikshay-sync.component.html new file mode 100644 index 00000000..ff9a3b3e --- /dev/null +++ b/src/app/app-modules/nikshay-sync/nikshay-sync.component.html @@ -0,0 +1,168 @@ + + + + +
+

Nikshay Sync

+ + + +
+

+ Download this camp's beneficiaries as a CSV formatted for the Nikshay ID Generator. +

+ +
+ local_shipping + {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} +
+
+ info + + No active camp session found — select a van and service point first. + +
+ +
+ + Camp Date Range + + + + + MM/DD/YYYY – MM/DD/YYYY + + + + Both dates are required + + + +
+ +
+
+
+
+ + +
+

+ Upload the Nikshay ID Generator app's results CSV to write the generated Nikshay IDs + back onto this camp's beneficiaries. +

+ +
+ local_shipping + {{ campInfo.vanLabel }} · {{ campInfo.servicePointName }} +
+
+ info + + No active camp session found — select a van and service point first. + +
+ +
+
+ + + +
+ +
+ +
+
+ +
+

+ {{ lastImportSummary.updated }} Nikshay ID(s) updated · + {{ lastImportSummary.needsReview }} row(s) need review · + {{ lastImportSummary.failed }} failed + (of {{ lastImportSummary.csvRowCount }} row(s) in the file) +

+ +
+ Needs review: +
    +
  • + {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — + {{ row.note }} +
  • +
+
+ +
+ Failed: +
    +
  • + {{ row.firstName }} {{ row.middleLastName }} (benRegId {{ row.benRegId }}) — + {{ row.note }} +
  • +
+
+
+
+
+
+
+ + diff --git a/src/app/app-modules/nikshay-sync/nikshay-sync.component.ts b/src/app/app-modules/nikshay-sync/nikshay-sync.component.ts new file mode 100644 index 00000000..1dd53774 --- /dev/null +++ b/src/app/app-modules/nikshay-sync/nikshay-sync.component.ts @@ -0,0 +1,269 @@ +/* + * 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 { Component, OnInit } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { FormBuilder, Validators } from '@angular/forms'; +import { SessionStorageService } from 'Common-UI/src/registrar/services/session-storage.service'; +import { saveAs } from 'file-saver'; +import { environment } from 'src/environments/environment'; +import { ConfirmationService } from '../core/services/confirmation.service'; + +interface CampInfo { + vanID: string; + vanLabel: string; + servicePointID: string; + servicePointName: string; +} + +interface ImportRowResult { + rowIndex: number; + benRegId: number | null; + firstName: string; + middleLastName: string; + status: string; + generatedId: string; + note: string; +} + +interface ImportSummary { + csvRowCount: number; + updated: number; + failed: number; + needsReview: number; + needsReviewRows: ImportRowResult[]; + failedRows: ImportRowResult[]; +} + +/** Keeps a single date-range download from spanning so many days that + * generating/transferring the CSV becomes slow enough to feel like it's + * hanging the browser tab. */ +const MAX_RANGE_DAYS = 90; + +@Component({ + selector: 'app-nikshay-sync', + templateUrl: './nikshay-sync.component.html', + styleUrls: ['./nikshay-sync.component.css'], +}) +export class NikshaySyncComponent implements OnInit { + // ── Download Beneficiaries (Nikshay CSV) tab ── + today = new Date(); + isDownloadingCsv = false; + campInfo: CampInfo | null = null; + + downloadForm = this.fb.group({ + fromDate: [null as Date | null, Validators.required], + toDate: [null as Date | null, Validators.required], + }); + + // ── Upload Nikshay Results tab ── + isUploadingCsv = false; + selectedResultsFile: File | null = null; + lastImportSummary: ImportSummary | null = null; + + constructor( + private fb: FormBuilder, + private http: HttpClient, + private sessionstorage: SessionStorageService, + private confirmationService: ConfirmationService + ) {} + + ngOnInit(): void { + this.loadCampInfo(); + } + + private loadCampInfo(): void { + try { + const serviceLine = JSON.parse( + this.sessionstorage.getItem('serviceLineDetails') ?? 'null' + ); + const servicePointID = this.sessionstorage.getItem('servicePointID'); + const servicePointName = this.sessionstorage.getItem('servicePointName'); + this.campInfo = + serviceLine?.vanID && servicePointID + ? { + vanID: serviceLine.vanID, + vanLabel: + serviceLine.vanNoAndType || + serviceLine.vehicalNo || + `Van ${serviceLine.vanID}`, + servicePointID, + servicePointName: servicePointName || `#${servicePointID}`, + } + : null; + } catch { + this.campInfo = null; + } + } + + downloadBeneficiariesCsv(): void { + if (this.downloadForm.invalid) { + this.downloadForm.markAllAsTouched(); + return; + } + if (!this.campInfo) { + this.confirmationService.alert( + 'No active camp session found — select a van and service point first.', + 'error' + ); + return; + } + + const fromDate = this.downloadForm.value.fromDate as Date; + const toDate = this.downloadForm.value.toDate as Date; + const rangeDays = + Math.round( + (toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24) + ) + 1; + + if (rangeDays < 1) { + this.confirmationService.alert( + 'The "From" date must be on or before the "To" date.', + 'error' + ); + return; + } + if (rangeDays > MAX_RANGE_DAYS) { + this.confirmationService.alert( + `Please choose a range of ${MAX_RANGE_DAYS} days or fewer so the download stays quick.`, + 'warning' + ); + return; + } + + const params = { + fromDate: this.formatDate(fromDate), + toDate: this.formatDate(toDate), + vanID: String(this.campInfo.vanID), + servicePointID: String(this.campInfo.servicePointID), + }; + + this.isDownloadingCsv = true; + this.http + .get(environment.nikshayBeneficiaryCsvUrl, { + params, + responseType: 'blob', + observe: 'response', + }) + .subscribe({ + next: (res: HttpResponse) => { + this.isDownloadingCsv = false; + const body = res.body; + if (!body || body.size === 0) { + this.confirmationService.alert( + 'No beneficiaries found for the selected date range.', + 'info' + ); + return; + } + const filename = this.extractFilename( + res.headers.get('content-disposition'), + `nikshay-beneficiaries-${params.fromDate}-to-${params.toDate}.csv` + ); + saveAs(body, filename); + }, + error: () => { + this.isDownloadingCsv = false; + this.confirmationService.alert( + 'Could not download the beneficiary CSV. Please try again.', + 'error' + ); + }, + }); + } + + onResultsFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + this.selectedResultsFile = input.files?.[0] ?? null; + this.lastImportSummary = null; + } + + uploadResultsCsv(): void { + if (!this.campInfo) { + this.confirmationService.alert( + 'No active camp session found — select a van and service point first.', + 'error' + ); + return; + } + if (!this.selectedResultsFile) { + this.confirmationService.alert( + 'Please choose the Nikshay results CSV file to upload.', + 'error' + ); + return; + } + + const formData = new FormData(); + formData.append('file', this.selectedResultsFile); + + const params = { + vanID: String(this.campInfo.vanID), + servicePointID: String(this.campInfo.servicePointID), + visitDate: this.formatDate(new Date()), + }; + + this.isUploadingCsv = true; + this.lastImportSummary = null; + this.http + .post(environment.nikshayImportResultsCsvUrl, formData, { + params, + }) + .subscribe({ + next: summary => { + this.isUploadingCsv = false; + this.lastImportSummary = summary; + const needsAttention = summary.failed > 0 || summary.needsReview > 0; + this.confirmationService.alert( + `Import complete — ${summary.updated} Nikshay ID(s) updated, ` + + `${summary.needsReview} row(s) need review, ${summary.failed} failed.`, + needsAttention ? 'warning' : 'info' + ); + }, + error: err => { + this.isUploadingCsv = false; + const message = + typeof err?.error === 'string' + ? err.error + : 'Could not import the results CSV. Please try again.'; + this.confirmationService.alert(message, 'error'); + }, + }); + } + + private formatDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + private extractFilename( + contentDisposition: string | null, + fallback: string + ): string { + const match = contentDisposition + ? /filename="?([^";]+)"?/i.exec(contentDisposition) + : null; + return match ? match[1] : fallback; + } +} diff --git a/src/app/app-modules/nikshay-sync/nikshay-sync.module.ts b/src/app/app-modules/nikshay-sync/nikshay-sync.module.ts new file mode 100644 index 00000000..fe6be6b9 --- /dev/null +++ b/src/app/app-modules/nikshay-sync/nikshay-sync.module.ts @@ -0,0 +1,49 @@ +/* + * 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; +import { MaterialModule } from '../core/material.module'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../core/components/shared/shared.module'; + +import { NikshaySyncRoutingModule } from './nikshay-sync-routing.module'; +import { NikshaySyncComponent } from './nikshay-sync.component'; + +@NgModule({ + imports: [ + CommonModule, + NikshaySyncRoutingModule, + CoreModule, + ReactiveFormsModule, + FormsModule, + MaterialModule, + MatFormFieldModule, + MatButtonModule, + SharedModule, + ], + declarations: [NikshaySyncComponent], +}) +export class NikshaySyncModule {} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 565bd019..557c57fe 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -109,6 +109,14 @@ const routes: Routes = [ module => module.DataSYNCModule ), }, + { + path: 'nikshay-sync', + canActivate: [AuthGuard], + loadChildren: () => + import('./app-modules/nikshay-sync/nikshay-sync.module').then( + module => module.NikshaySyncModule + ), + }, { path: 'feedback', loadChildren: () => From 8448ec0a188545a734745854c14ac1e5febc7c00 Mon Sep 17 00:00:00 2001 From: Sneha Date: Wed, 26 Aug 2026 11:43:36 +0530 Subject: [PATCH 3/3] feat(stoptb): gate Nikshay Sync nav item behind Stop TB env flag Hides the Nikshay Sync header tab outside Stop TB deployments by reusing environment.isMMUOfflineQRCode, the same flag that gates the Camp Hub QR code button on the login page. The Camp Hub results upload tab needed no change: that dialog is only reachable through openQrDialog(), already behind the same flag. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/components/app-header/app-header.component.html | 2 +- .../core/components/app-header/app-header.component.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/app-modules/core/components/app-header/app-header.component.html b/src/app/app-modules/core/components/app-header/app-header.component.html index ec7ab78e..e72389ba 100644 --- a/src/app/app-modules/core/components/app-header/app-header.component.html +++ b/src/app/app-modules/core/components/app-header/app-header.component.html @@ -159,7 +159,7 @@ {{ currentLanguageSet?.common?.report }} -