fix(clients): minor-age threshold to 18, senior/PWD doc reload, in-app document preview dialog
- Raise isMinor threshold from age <10 to <18 (legal definition), cascading correctly to guardian requirement, KYC document filtering, and Education Level/Degree Qualification visibility (now shown for all ages, not hidden for minors). - Load and display previously-uploaded Senior Citizen proof / PWD certificate documents on edit (view/replace/remove), instead of always showing an empty upload prompt. - Fix seniorPwdDocsComplete to accept an existing certificate, not just a freshly-picked file. - Add DocumentPreviewDialogComponent: a reusable in-app modal (image/PDF/download) used in place of window.open() for saved KYC/address/senior-citizen/PWD documents — avoids popup blockers (fired only after the async download resolved) and Chrome/Edge's failure to paint large base64 data: URLs on first open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+81
-63
@@ -365,7 +365,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="guardian-section">
|
||||
<!-- Header bar -->
|
||||
@@ -1035,69 +1035,67 @@
|
||||
Education & Social Details
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<!-- Education Level (non-minor) -->
|
||||
@if (!isMinor) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Education Level -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Religion -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1322,6 +1320,8 @@
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||
}
|
||||
@@ -1330,6 +1330,13 @@
|
||||
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1354,12 +1361,16 @@
|
||||
<div class="full-width doc-upload-field">
|
||||
<label
|
||||
class="doc-upload-label"
|
||||
[class.doc-upload-label--missing]="seniorPwdDocsTouched && !pwdCertificateFile"
|
||||
[class.doc-upload-label--missing]="
|
||||
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||
"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (pwdCertificateFile) {
|
||||
<span>{{ pwdCertificateFile.name }}</span>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||
}
|
||||
@@ -1368,8 +1379,15 @@
|
||||
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile) {
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
+98
-3
@@ -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) {
|
||||
|
||||
+8
-21
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
+1
-1
@@ -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' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ data.title || data.fileName || 'Document Preview' }}</h1>
|
||||
|
||||
<div mat-dialog-content class="doc-preview-content">
|
||||
@if (isImage) {
|
||||
<img [src]="previewUrl" [alt]="data.fileName || 'Document'" class="doc-preview-image" />
|
||||
} @else if (isPdf) {
|
||||
<iframe [src]="safeUrl" class="doc-preview-pdf" title="Document preview"></iframe>
|
||||
} @else {
|
||||
<p>Preview isn't available for this file type — use Download to view it.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button (click)="download()">Download</button>
|
||||
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DocumentPreviewDialogComponent>>(MatDialogRef);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
data = inject<DocumentPreviewDialogData>(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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user