diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index fc4fa013b..3483e695c 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -365,7 +365,7 @@ } - + @if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
@@ -1035,69 +1035,67 @@ Education & Social Details
- - @if (!isMinor) { - - Education Level - - - @if (getSelectedEducationLevel()) { - - {{ - getEducationLevelIcon(getSelectedEducationLevel().name) - }} - {{ getSelectedEducationLevel().name }} - - } - - @for (el of educationLevelOptions; track el) { - - - {{ getEducationLevelIcon(el.name) }} - {{ el.name }} - - + + + Education Level + + + @if (getSelectedEducationLevel()) { + + {{ + getEducationLevelIcon(getSelectedEducationLevel().name) + }} + {{ getSelectedEducationLevel().name }} + } - - + + @for (el of educationLevelOptions; track el) { + + + {{ getEducationLevelIcon(el.name) }} + {{ el.name }} + + + } + + - - - Degree / Qualification - - - @if (getSelectedDegreeQualification()) { - - {{ - getDegreeQualificationIcon(getSelectedDegreeQualification().name) - }} - {{ getSelectedDegreeQualification().name }} - - } - - @for (dq of filteredDegreeQualificationOptions; track dq) { - - - {{ getDegreeQualificationIcon(dq.name) }} - {{ dq.name }} - - + + + Degree / Qualification + + + @if (getSelectedDegreeQualification()) { + + {{ + getDegreeQualificationIcon(getSelectedDegreeQualification().name) + }} + {{ getSelectedDegreeQualification().name }} + } - @if (!getSelectedEducationLevel()) { - - Please select Education Level first - - } @else if (filteredDegreeQualificationOptions.length === 0) { - - No options available - - } - - - } + + @for (dq of filteredDegreeQualificationOptions; track dq) { + + + {{ getDegreeQualificationIcon(dq.name) }} + {{ dq.name }} + + + } + @if (!getSelectedEducationLevel()) { + + Please select Education Level first + + } @else if (filteredDegreeQualificationOptions.length === 0) { + + No options available + + } + + @@ -1322,6 +1320,8 @@ @if (seniorCitizenProofFile) { {{ seniorCitizenProofFile.name }} + } @else if (existingSeniorCitizenProofDoc) { + {{ existingSeniorCitizenProofDoc.name }} (uploaded) } @else { Upload Senior Citizen Proof (Age / ID Proof) — optional } @@ -1330,6 +1330,13 @@ + } @else if (existingSeniorCitizenProofDoc) { + + }
} @@ -1354,12 +1361,16 @@
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 46edf0be4..21c7bfa4d 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select'; import { MatTooltip } from '@angular/material/tooltip'; import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component'; import { DipVerifyDialogComponent } from './dip-verify-dialog.component'; @@ -98,6 +99,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges seniorCitizenProofFile: File | null = null; pwdCertificateFile: File | null = null; + /** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to + * "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */ + existingSeniorCitizenProofDoc: { id: string; name: string } | null = null; + existingPwdCertificateDoc: { id: string; name: string } | null = null; + /** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from * m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions. * Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */ @@ -111,7 +117,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges get seniorPwdDocsComplete(): boolean { const pwdOk = !this.createClientForm.get('isPwd')?.value || - (!!this.pwdCertificateFile && !!this.createClientForm.get('pwdCategoryId')?.value); + ((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) && + !!this.createClientForm.get('pwdCategoryId')?.value); return pwdOk; } @@ -141,6 +148,93 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.pwdCertificateFile = null; } + /** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode — + * mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's + * already on file instead of an empty upload prompt. */ + private loadExistingSeniorPwdDocuments(): void { + const clientId = this.clientData?.id; + if (!clientId) { + return; + } + this.clientService.getClientDocuments(clientId.toString()).subscribe({ + next: (documents: any[]) => { + const seniorDoc = documents + ?.filter((d: any) => d.name === 'seniorCitizenProof') + ?.sort((a: any, b: any) => b.id - a.id)?.[0]; + if (seniorDoc?.id) { + this.existingSeniorCitizenProofDoc = { + id: seniorDoc.id.toString(), + name: seniorDoc.fileName || 'Senior Citizen Proof' + }; + } + const pwdDoc = documents + ?.filter((d: any) => d.name === 'pwdCertificate') + ?.sort((a: any, b: any) => b.id - a.id)?.[0]; + if (pwdDoc?.id) { + this.existingPwdCertificateDoc = { + id: pwdDoc.id.toString(), + name: pwdDoc.fileName || 'Disability Certificate' + }; + } + }, + error: () => {} + }); + } + + viewExistingSeniorCitizenProof(): void { + const clientId = this.clientData?.id; + if (!clientId || !this.existingSeniorCitizenProofDoc) { + return; + } + this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({ + next: (blob: any) => + this.dialog.open(DocumentPreviewDialogComponent, { + data: { + blob, + title: this.existingSeniorCitizenProofDoc?.name, + fileName: this.existingSeniorCitizenProofDoc?.name + } + }), + error: () => {} + }); + } + + removeExistingSeniorCitizenProof(): void { + const clientId = this.clientData?.id; + if (!clientId || !this.existingSeniorCitizenProofDoc) { + return; + } + this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({ + next: () => (this.existingSeniorCitizenProofDoc = null), + error: () => {} + }); + } + + viewExistingPwdCertificate(): void { + const clientId = this.clientData?.id; + if (!clientId || !this.existingPwdCertificateDoc) { + return; + } + this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({ + next: (blob: any) => + this.dialog.open(DocumentPreviewDialogComponent, { + data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name } + }), + error: () => {} + }); + } + + removeExistingPwdCertificate(): void { + const clientId = this.clientData?.id; + if (!clientId || !this.existingPwdCertificateDoc) { + return; + } + this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({ + next: () => (this.existingPwdCertificateDoc = null), + error: () => {} + }); + } + /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum date allowed. */ @@ -203,7 +297,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges ]; /** Calculated age detail string (Y/M/D) */ calculatedAgeDetail: string | null = null; - /** Whether client is a minor (age < 10) */ + /** Whether client is a minor (age < 18) */ isMinor = false; /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */ isSeniorCitizen = false; @@ -600,6 +694,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges */ private prefillForEdit() { const data = this.clientData; + this.loadExistingSeniorPwdDocuments(); // Patch all base controls that exist on the form this.createClientForm.patchValue({ @@ -980,7 +1075,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`; this.ageBreakdown = { years, months, days }; const wasMinor = this.isMinor; - this.isMinor = years < 10; + this.isMinor = years < 18; this.isSeniorCitizen = years >= 60; // Add/remove guardian form group based on minor status if (this.isMinor && !wasMinor) { diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts index e02b48b3f..fdf2f9250 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts @@ -15,9 +15,11 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIcon } from '@angular/material/icon'; import { MatIconButton, MatButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { ClientsService } from '../../clients.service'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; +import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; @Component({ selector: 'mifosx-client-india-address-step', @@ -38,6 +40,7 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { private http = inject(HttpClient); private clientsService = inject(ClientsService); private kycUpload = inject(KycUploadHelperService); + private dialog = inject(MatDialog); /** Client ID — when set, loads existing addresses for edit mode */ @Input() clientId: string = ''; @@ -633,33 +636,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { }); } - /** - * Open the saved address proof in a new browser tab. - * - * For images we already hold a base64 data URL in `previewUrl` from - * `loadExistingAddressProof`, so we can hand it straight to `window.open`. - * For PDFs / other types we need to fetch the binary blob and create a - * temporary object URL so the browser's PDF viewer can render it. - */ + /** Open the saved address proof in an in-app preview dialog. */ viewExistingAddressProof(): void { if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) { return; } const doc = this.existingAddressProofDocuments[0]; - if (doc.previewUrl) { - window.open(doc.previewUrl, '_blank', 'noopener'); - return; - } this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({ - next: (blob: Blob) => { - const url = URL.createObjectURL(blob); - const w = window.open(url, '_blank', 'noopener'); - // Revoke after the tab has had time to load — browsers vary, so - // err on the side of a generous wait. - if (w) { - setTimeout(() => URL.revokeObjectURL(url), 60_000); - } - }, + next: (blob: Blob) => + this.dialog.open(DocumentPreviewDialogComponent, { + data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name } + }), error: () => { // Silent fail — UI shows the saved name so user knows it exists. } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 3a6f0b97a..85cb2c115 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -32,6 +32,7 @@ import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shar import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { dataUrlToFile } from 'app/shared/utils/data-url.util'; +import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component'; @Component({ selector: 'mifosx-client-kyc-step', @@ -695,30 +696,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { this.kycDocMergedPreview = null; } - /** - * Open the saved KYC ID-proof document in a new browser tab. - * - * Same flow as the address-step variant: images use the already-loaded - * base64 data URL; PDFs (or anything without a cached preview) are fetched - * fresh and rendered via a temporary blob URL. - */ + /** Open the saved KYC ID-proof document in an in-app preview dialog. */ viewExistingKycDoc(): void { if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) { return; } const doc = this.existingKycDocuments[0]; - if (doc.previewUrl) { - window.open(doc.previewUrl, '_blank', 'noopener'); - return; - } this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({ - next: (blob: Blob) => { - const url = URL.createObjectURL(blob); - const w = window.open(url, '_blank', 'noopener'); - if (w) { - setTimeout(() => URL.revokeObjectURL(url), 60_000); - } - }, + next: (blob: Blob) => + this.dialog.open(DocumentPreviewDialogComponent, { + data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name } + }), error: () => { /* silent — UI still shows the saved filename */ } diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html index eefd2a892..00857b355 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html @@ -195,7 +195,7 @@ client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified' " > - {{ client.isMinor ? 'Yes (Age < 10)' : 'No' }} + {{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index 7086ffacb..42dcb79ba 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -105,7 +105,7 @@ [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor" > - {{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }} + {{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }} diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index b9c9f641e..f253ba7e9 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -440,6 +440,26 @@ export class EditClientComponent { this.savingClient = false; this.tabEditMode[tabIndex] = false; + // Senior Citizen / PWD supporting documents — same pattern as create-client's + // post-creation upload, triggered here since these are plain file inputs on the + // General step, not part of the JSON update payload. + if (this.clientGeneralStep?.seniorCitizenProofFile) { + const seniorCitizenProofForm = new FormData(); + seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile); + seniorCitizenProofForm.append('name', 'seniorCitizenProof'); + seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)'); + this.clientsService + .uploadClientDocument(this.clientId, seniorCitizenProofForm) + .subscribe({ error: () => {} }); + } + if (this.clientGeneralStep?.pwdCertificateFile) { + const pwdCertificateForm = new FormData(); + pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile); + pwdCertificateForm.append('name', 'pwdCertificate'); + pwdCertificateForm.append('description', 'Disability Certificate'); + this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} }); + } + // Log mobile/email changes as client notes for risk audit this.logFieldChangeNotes(changedFields); diff --git a/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.html b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.html new file mode 100644 index 000000000..bd1345b7e --- /dev/null +++ b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.html @@ -0,0 +1,24 @@ + + +

{{ data.title || data.fileName || 'Document Preview' }}

+ +
+ @if (isImage) { + + } @else if (isPdf) { + + } @else { +

Preview isn't available for this file type — use Download to view it.

+ } +
+ + + + + diff --git a/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.scss b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.scss new file mode 100644 index 000000000..49ee7d045 --- /dev/null +++ b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.doc-preview-content { + display: flex; + justify-content: center; + align-items: center; + min-width: 320px; + max-width: 80vw; +} + +.doc-preview-image { + max-width: 100%; + max-height: 70vh; + object-fit: contain; +} + +.doc-preview-pdf { + width: 70vw; + height: 70vh; + border: none; +} diff --git a/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.ts b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.ts new file mode 100644 index 000000000..84d9ff366 --- /dev/null +++ b/src/app/clients/shared/document-preview-dialog/document-preview-dialog.component.ts @@ -0,0 +1,81 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnDestroy, inject } from '@angular/core'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +export interface DocumentPreviewDialogData { + blob: Blob; + title?: string; + fileName?: string; +} + +/** + * Generic in-app document preview dialog — shows an image inline or a PDF in an embedded + * viewer, with a Download fallback for anything else. Used in place of window.open() for + * saved KYC/address/senior-citizen/PWD documents: opening a new browser tab is subject to + * popup blockers (fires only after the async download resolves) and, for images, Chrome/Edge + * frequently fail to paint a huge base64 data: URL until the tab is manually refreshed. + */ +@Component({ + selector: 'mifosx-document-preview-dialog', + templateUrl: './document-preview-dialog.component.html', + styleUrls: ['./document-preview-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose + ] +}) +export class DocumentPreviewDialogComponent implements OnDestroy { + dialogRef = inject>(MatDialogRef); + private sanitizer = inject(DomSanitizer); + data = inject(MAT_DIALOG_DATA); + + private readonly objectUrl: string; + readonly safeUrl: SafeResourceUrl; + readonly isImage: boolean; + readonly isPdf: boolean; + + constructor() { + this.objectUrl = URL.createObjectURL(this.data.blob); + this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.objectUrl); + const type = this.data.blob.type || ''; + this.isImage = type.startsWith('image/'); + this.isPdf = type === 'application/pdf'; + } + + ngOnDestroy(): void { + URL.revokeObjectURL(this.objectUrl); + } + + get previewUrl(): string { + return this.objectUrl; + } + + download(): void { + const a = document.createElement('a'); + a.href = this.objectUrl; + a.download = this.data.fileName || 'document'; + a.click(); + } +}