Compare commits
27
Commits
d0536dbb11
...
uate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7d8287f9 | ||
|
|
a560cd6533 | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 | ||
|
|
a9c60083bf | ||
|
|
0c99dc3686 | ||
|
|
733d9d22b2 | ||
|
|
d334dd1669 |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||||
|
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||||
|
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||||
|
* client's risk-category change history.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AmlService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// ═══ Alerts (AML Officer) ═══
|
||||||
|
|
||||||
|
getAlerts(status?: string): Observable<any[]> {
|
||||||
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||||
|
return this.http.get<any[]>('/aml/alerts', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||||
|
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ STR cases (Principal Officer) ═══
|
||||||
|
|
||||||
|
getStrCases(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrCase(strCaseId: number): Observable<any> {
|
||||||
|
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeeklyDigest(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||||
|
}
|
||||||
|
|
||||||
|
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||||
|
concludeStrCase(
|
||||||
|
strCaseId: number,
|
||||||
|
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||||
|
investigationNotes: string,
|
||||||
|
sourceOfFundsNotes: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||||
|
conclusion,
|
||||||
|
investigationNotes,
|
||||||
|
sourceOfFundsNotes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||||
|
|
||||||
|
getCtrReports(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/ctr-reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** month format: yyyy-MM */
|
||||||
|
generateCtrReport(month: string): Observable<any> {
|
||||||
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Client risk-category history ═══
|
||||||
|
|
||||||
|
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -394,7 +394,12 @@ export class JointHolderDialogComponent implements OnInit {
|
|||||||
this.kyc = kycArr[0] ?? null;
|
this.kyc = kycArr[0] ?? null;
|
||||||
this.photo = photo ?? null;
|
this.photo = photo ?? null;
|
||||||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
@@ -1868,7 +1873,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||||
this.clientPhotoUrl = r.photo ?? null;
|
this.clientPhotoUrl = r.photo ?? null;
|
||||||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||||
|
|||||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document named 'clientSignature'
|
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// clients who re-cropped/replaced their signature before uploads became single-slot, so
|
||||||
|
// pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.viewDetails.docs.signature = {
|
this.viewDetails.docs.signature = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ export class SignatoryService {
|
|||||||
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||||
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||||
switchMap((docs: any[]) => {
|
switchMap((docs: any[]) => {
|
||||||
const sigDoc = (docs || []).find(
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
);
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = (docs || [])
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
if (!sigDoc) {
|
if (!sigDoc) {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -62,6 +62,22 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Mother is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Spouse hint for married clients -->
|
<!-- Spouse hint for married clients -->
|
||||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||||
<div class="fm-spouse-hint">
|
<div class="fm-spouse-hint">
|
||||||
|
|||||||
+44
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
|
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
ref.afterClosed().subscribe((response: any) => {
|
ref.afterClosed().subscribe((response: any) => {
|
||||||
if (response?.member) {
|
if (response?.member) {
|
||||||
this.clientFamilyMembers.push(response.member);
|
this.clientFamilyMembers.push(response.member);
|
||||||
|
this.showFamilyMemberAddedConfirmation(response.member);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||||
|
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||||
|
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||||
|
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||||
|
width: '360px',
|
||||||
|
data: {
|
||||||
|
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||||
|
name: member._linkedClient?.displayName || member.firstName,
|
||||||
|
relationship: this.getRelName(member.relationshipId),
|
||||||
|
dateOfBirth: member.dateOfBirth,
|
||||||
|
isDependent: member.isDependent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
return this.clientFamilyMembers.length > 0;
|
return this.clientFamilyMembers.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||||
|
private hasRelationship(name: string): boolean {
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
return this.clientFamilyMembers.some((m: any) => {
|
||||||
|
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||||
|
if (resolved === target) return true;
|
||||||
|
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMother(): boolean {
|
||||||
|
return this.hasRelationship('mother');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFather(): boolean {
|
||||||
|
return this.hasRelationship('father');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasCaretaker(): boolean {
|
||||||
|
return this.hasRelationship('caretaker');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||||
|
get familyRelationshipRulesMet(): boolean {
|
||||||
|
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if client's marital status is "Married" */
|
/** Check if client's marital status is "Married" */
|
||||||
get isMarried(): boolean {
|
get isMarried(): boolean {
|
||||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||||
|
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||||
|
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||||
|
* closed silently before this with no acknowledgement of what got recorded.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-family-member-added-dialog',
|
||||||
|
imports: [
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatIcon,
|
||||||
|
MatButton
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title class="fma-title">
|
||||||
|
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||||
|
Family Member Added
|
||||||
|
</h2>
|
||||||
|
<div class="fma-body">
|
||||||
|
@if (data.clientId) {
|
||||||
|
<div class="fma-row fma-row--id">
|
||||||
|
<span class="fma-row__label">Client ID</span>
|
||||||
|
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Name</span>
|
||||||
|
<span class="fma-row__value">{{ data.name }}</span>
|
||||||
|
</div>
|
||||||
|
@if (data.relationship) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Relationship</span>
|
||||||
|
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.dateOfBirth) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Date of Birth</span>
|
||||||
|
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.isDependent) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Dependent</span>
|
||||||
|
<span class="fma-row__value">Yes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.fma-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-title__icon {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 24px 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row--id {
|
||||||
|
background: #eff6ff;
|
||||||
|
margin: 0 -24px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value--id {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
color: #1353a0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 8px 24px 16px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FamilyMemberAddedDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||||
|
data: {
|
||||||
|
clientId?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
relationship?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
isDependent?: boolean;
|
||||||
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
+223
-70
@@ -136,7 +136,7 @@
|
|||||||
| translate
|
| translate
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<input matInput required formControlName="fullname" />
|
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field first-name">
|
<mat-form-field appearance="outline" class="name-field first-name">
|
||||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||||
<input matInput required formControlName="firstname" />
|
<input matInput required formControlName="firstname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||||
<mat-error>First name is <strong>required</strong></mat-error>
|
<mat-error>First name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||||
<input matInput formControlName="middlename" />
|
<input matInput formControlName="middlename" autocomplete="off" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||||
createClientForm.controls.middlename?.touched
|
createClientForm.controls.middlename?.touched
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field last-name">
|
<mat-form-field appearance="outline" class="name-field last-name">
|
||||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||||
<input matInput required formControlName="lastname" />
|
<input matInput required formControlName="lastname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||||
@@ -358,11 +358,14 @@
|
|||||||
@if (isMinor) {
|
@if (isMinor) {
|
||||||
<span class="minor-badge">Minor</span>
|
<span class="minor-badge">Minor</span>
|
||||||
}
|
}
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="guardian-section">
|
<div class="guardian-section">
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
@@ -995,6 +998,32 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- CLIENT CREATION contact-OTP verification — create mode only. Edit mode already has
|
||||||
|
its own dual old/new OTP panel in EditClientComponent's template; this single
|
||||||
|
"new value" block only makes sense for a brand-new client (no "old" destination to
|
||||||
|
verify against). Each block only appears once its own field passes validation, so
|
||||||
|
staff enter a plausible-looking number/email before being asked to verify it. -->
|
||||||
|
@if (!clientData) {
|
||||||
|
<div class="contact-otp-row">
|
||||||
|
@if (createClientForm.get('mobileNo')?.valid) {
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
channel="MOBILE"
|
||||||
|
[destination]="createClientForm.get('mobileNo')?.value"
|
||||||
|
label="Verify Mobile Number"
|
||||||
|
(stateChange)="onMobileOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
}
|
||||||
|
@if (createClientForm.get('emailAddress')?.valid) {
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
channel="EMAIL"
|
||||||
|
[destination]="createClientForm.get('emailAddress')?.value"
|
||||||
|
label="Verify Email Address"
|
||||||
|
(stateChange)="onEmailOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1006,69 +1035,67 @@
|
|||||||
Education & Social Details
|
Education & Social Details
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Education Level (non-minor) -->
|
<!-- Education Level -->
|
||||||
@if (!isMinor) {
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-label>Education Level</mat-label>
|
||||||
<mat-label>Education Level</mat-label>
|
<mat-select formControlName="educationLevelId">
|
||||||
<mat-select formControlName="educationLevelId">
|
<mat-select-trigger>
|
||||||
<mat-select-trigger>
|
@if (getSelectedEducationLevel()) {
|
||||||
@if (getSelectedEducationLevel()) {
|
<span class="icon-option">
|
||||||
<span class="icon-option">
|
<mat-icon class="icon-option-icon">{{
|
||||||
<mat-icon class="icon-option-icon">{{
|
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
}}</mat-icon>
|
||||||
}}</mat-icon>
|
{{ getSelectedEducationLevel().name }}
|
||||||
{{ getSelectedEducationLevel().name }}
|
</span>
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select-trigger>
|
||||||
</mat-form-field>
|
@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) -->
|
<!-- Degree / Qualification (depends on Education Level) -->
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Degree / Qualification</mat-label>
|
<mat-label>Degree / Qualification</mat-label>
|
||||||
<mat-select
|
<mat-select
|
||||||
formControlName="degreeQualificationId"
|
formControlName="degreeQualificationId"
|
||||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||||
>
|
>
|
||||||
<mat-select-trigger>
|
<mat-select-trigger>
|
||||||
@if (getSelectedDegreeQualification()) {
|
@if (getSelectedDegreeQualification()) {
|
||||||
<span class="icon-option">
|
<span class="icon-option">
|
||||||
<mat-icon class="icon-option-icon">{{
|
<mat-icon class="icon-option-icon">{{
|
||||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
{{ getSelectedDegreeQualification().name }}
|
{{ getSelectedDegreeQualification().name }}
|
||||||
</span>
|
</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>
|
|
||||||
}
|
}
|
||||||
@if (!getSelectedEducationLevel()) {
|
</mat-select-trigger>
|
||||||
<mat-option disabled>
|
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||||
<em>Please select Education Level first</em>
|
<mat-option [value]="dq.id">
|
||||||
</mat-option>
|
<span class="icon-option">
|
||||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||||
<mat-option disabled>
|
{{ dq.name }}
|
||||||
<em>No options available</em>
|
</span>
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
@if (!getSelectedEducationLevel()) {
|
||||||
</mat-form-field>
|
<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 -->
|
<!-- Religion -->
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1191,21 +1218,25 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Annual Turnover (Last FY)</mat-label>
|
<mat-label>Annual Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||||
createClientForm.get('annualTurnover')?.touched
|
|
||||||
) {
|
) {
|
||||||
<mat-error>Enter a valid amount</mat-error>
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
} @else if (
|
} @else if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||||
|
Other Income
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Agriculture Income</mat-label>
|
<mat-label>Agriculture Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
@@ -1239,6 +1270,128 @@
|
|||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Monthly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Yearly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||||
|
Senior Citizen / Person with Disability
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<div class="full-width senior-citizen-auto-note">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
Senior Citizen (60+ years) — detected automatically from Date of Birth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label class="doc-upload-label">
|
||||||
|
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (seniorCitizenProofFile) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="checkbox-field full-width">
|
||||||
|
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (createClientForm.get('isPwd')?.value) {
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Disability Type</mat-label>
|
||||||
|
<mat-select formControlName="pwdCategoryId">
|
||||||
|
@for (category of pwdCategoryOptions; track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
|
||||||
|
<mat-error>Disability type is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label
|
||||||
|
class="doc-upload-label"
|
||||||
|
[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>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (pwdCertificateFile) {
|
||||||
|
<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 && !existingPwdCertificateDoc) {
|
||||||
|
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -56,6 +56,78 @@ form {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-auto-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1074b9;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1074b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.doc-upload-label--missing {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-req {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 20px 0 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.subsection-icon {
|
||||||
|
color: var(--md-sys-color-primary, #1074b9);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-field {
|
.checkbox-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -234,6 +306,18 @@ form {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1074b9;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Guardian Section (Redesigned) ═══ */
|
/* ═══ Guardian Section (Redesigned) ═══ */
|
||||||
.guardian-section {
|
.guardian-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -1493,6 +1577,21 @@ form {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create-time mobile/email OTP verification — spans both `.form-grid` columns so the two
|
||||||
|
* blocks sit on their own full-width row instead of disrupting the field/verify-button pairing
|
||||||
|
* above them. */
|
||||||
|
.contact-otp-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
mifosx-client-contact-otp-block {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1516,6 +1615,10 @@ form {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-otp-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.guardian-card__avatar.has-photo {
|
.guardian-card__avatar.has-photo {
|
||||||
|
|||||||
+461
-7
@@ -46,10 +46,15 @@ import { MatSelectTrigger } from '@angular/material/select';
|
|||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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 { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||||
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
||||||
|
import {
|
||||||
|
ContactOtpBlockComponent,
|
||||||
|
ContactOtpState
|
||||||
|
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Client Component
|
* Create Client Component
|
||||||
@@ -68,7 +73,8 @@ import { DinVerifyDialogComponent } from './din-verify-dialog.component';
|
|||||||
MatIcon,
|
MatIcon,
|
||||||
MatTooltip,
|
MatTooltip,
|
||||||
MatSelectTrigger,
|
MatSelectTrigger,
|
||||||
DateFormatPipe
|
DateFormatPipe,
|
||||||
|
ContactOtpBlockComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges {
|
export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@@ -88,6 +94,148 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
/** Expose enum to template */
|
/** Expose enum to template */
|
||||||
readonly LegalFormId = LegalFormId;
|
readonly LegalFormId = LegalFormId;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
|
||||||
|
* attached, are uploaded after client creation. */
|
||||||
|
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. */
|
||||||
|
pwdCategoryOptions: any[] = [];
|
||||||
|
riskRules: any[] = [];
|
||||||
|
|
||||||
|
/** Set true after a save attempt so the UI can highlight a missing required document */
|
||||||
|
seniorPwdDocsTouched = false;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
|
||||||
|
* whenever the PWD checkbox is set. */
|
||||||
|
get seniorPwdDocsComplete(): boolean {
|
||||||
|
const pwdOk =
|
||||||
|
!this.createClientForm.get('isPwd')?.value ||
|
||||||
|
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
|
||||||
|
!!this.createClientForm.get('pwdCategoryId')?.value);
|
||||||
|
return pwdOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSeniorPwdDocsTouched(): void {
|
||||||
|
this.seniorPwdDocsTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeniorCitizenProofSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.seniorCitizenProofFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSeniorCitizenProof(): void {
|
||||||
|
this.seniorCitizenProofFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPwdCertificateSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.pwdCertificateFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePwdCertificate(): void {
|
||||||
|
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. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
/** Maximum date allowed. */
|
/** Maximum date allowed. */
|
||||||
@@ -150,8 +298,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
/** Calculated age detail string (Y/M/D) */
|
/** Calculated age detail string (Y/M/D) */
|
||||||
calculatedAgeDetail: string | null = null;
|
calculatedAgeDetail: string | null = null;
|
||||||
/** Whether client is a minor (age < 10) */
|
/** Whether client is a minor (age < 18) */
|
||||||
isMinor = false;
|
isMinor = false;
|
||||||
|
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
||||||
|
isSeniorCitizen = false;
|
||||||
/** Age breakdown for DB storage */
|
/** Age breakdown for DB storage */
|
||||||
ageBreakdown = { years: 0, months: 0, days: 0 };
|
ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
|
||||||
@@ -183,6 +333,45 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
alternateDipLookupLogId: number | null = null;
|
alternateDipLookupLogId: number | null = null;
|
||||||
/** Alternate DIP lookup timestamp from API */
|
/** Alternate DIP lookup timestamp from API */
|
||||||
alternateDipTimestamp: string | null = null;
|
alternateDipTimestamp: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
|
||||||
|
* edit-flow dual old/new OTP panel in `EditClientComponent`, a brand-new client has no "old"
|
||||||
|
* destination to verify against — so only a single "new value" OTP-verification is needed
|
||||||
|
* per field here. Sent to the backend as `mobileNewOtpId`/`emailNewOtpId` alongside the rest
|
||||||
|
* of the create-client payload (see `CreateClientComponent.saveAndProceed()`), which requires
|
||||||
|
* both whenever `mobileNo`/`emailAddress` are present (and they always are — both are
|
||||||
|
* `Validators.required` on this form).
|
||||||
|
*/
|
||||||
|
mobileOtpVerified = false;
|
||||||
|
mobileNewOtpId: number | null = null;
|
||||||
|
emailOtpVerified = false;
|
||||||
|
emailNewOtpId: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether both mobile and email have been OTP-verified for CLIENT CREATION. Always `true`
|
||||||
|
* in edit mode (`clientData` set) — edit-time contact verification is gated separately by
|
||||||
|
* `EditClientComponent.contactVerificationComplete`, not by this component.
|
||||||
|
*/
|
||||||
|
get contactVerified(): boolean {
|
||||||
|
if (this.clientData) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.mobileOtpVerified && this.emailOtpVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets create-time OTP verification for mobileNo — called whenever the field's value
|
||||||
|
* changes after a prior verification, since that OTP no longer applies to the new value. */
|
||||||
|
onMobileOtpState(state: ContactOtpState): void {
|
||||||
|
this.mobileOtpVerified = state.verified;
|
||||||
|
this.mobileNewOtpId = state.otpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets create-time OTP verification for emailAddress — see `onMobileOtpState()`. */
|
||||||
|
onEmailOtpState(state: ContactOtpState): void {
|
||||||
|
this.emailOtpVerified = state.verified;
|
||||||
|
this.emailNewOtpId = state.otpId;
|
||||||
|
}
|
||||||
/** Constitution Types Master with KYC field metadata */
|
/** Constitution Types Master with KYC field metadata */
|
||||||
constitutionTypesMaster = [
|
constitutionTypesMaster = [
|
||||||
{
|
{
|
||||||
@@ -358,6 +547,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.setOptions();
|
this.setOptions();
|
||||||
|
|
||||||
|
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
|
||||||
|
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
|
||||||
|
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
|
||||||
|
this.clientService.getClientRiskRules().subscribe({
|
||||||
|
next: (rules) => {
|
||||||
|
this.riskRules = rules || [];
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.riskRules = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
// Fetch marital status options from code values
|
// Fetch marital status options from code values
|
||||||
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
||||||
this.maritalStatusOptions = options || [];
|
this.maritalStatusOptions = options || [];
|
||||||
@@ -422,6 +623,51 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient session state back to a blank-client baseline.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps the SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create — `ngOnInit`/the
|
||||||
|
* constructor never re-run for a second "create new client" session. Without this, computed
|
||||||
|
* state like `isMinor`, the linked guardian, or a prior sanctions/OTP verification would leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent whenever `route.data` re-emits (which happens on every navigation,
|
||||||
|
* even a reused one).
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.createClientForm.reset();
|
||||||
|
// legalFormId's raw FormControl default is '' — the buildDependencies() subscription
|
||||||
|
// treats any non-PERSON value (including '') as "non-individual" and adds fullname /
|
||||||
|
// clientNonPersonDetails. Patch it back to the same PERSON default buildDependencies()
|
||||||
|
// itself applies on first construction, so a reset lands on a blank Individual client
|
||||||
|
// instead of stuck with leftover Entity-only controls.
|
||||||
|
this.createClientForm.get('legalFormId')?.setValue(LegalFormId.PERSON);
|
||||||
|
this.isMinor = false;
|
||||||
|
this.calculatedAgeDetail = null;
|
||||||
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
this.selectedGuardian = null;
|
||||||
|
this.guardianSearchText = '';
|
||||||
|
this.guardianSearchResults = [];
|
||||||
|
this.guardianExpanded = false;
|
||||||
|
if (this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
|
}
|
||||||
|
// Verification state must not carry over — a previous client's cleared sanctions
|
||||||
|
// screening or verified OTP must not silently apply to a different new client.
|
||||||
|
this.mobileVerified = false;
|
||||||
|
this.dipLookupLogId = null;
|
||||||
|
this.dipTimestamp = null;
|
||||||
|
this.alternateMobileVerified = false;
|
||||||
|
this.alternateDipLookupLogId = null;
|
||||||
|
this.alternateDipTimestamp = null;
|
||||||
|
this.mobileNewOtpId = null;
|
||||||
|
this.emailNewOtpId = null;
|
||||||
|
this.sanctionsVerified = false;
|
||||||
|
this.sanctionsBlocked = false;
|
||||||
|
this.screeningLogId = null;
|
||||||
|
this.verifiedName = '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-fills the General step form from a manually-entered family member
|
* Pre-fills the General step form from a manually-entered family member
|
||||||
* when the user clicks "Create as Client" on the family members tab.
|
* when the user clicks "Create as Client" on the family members tab.
|
||||||
@@ -461,6 +707,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
*/
|
*/
|
||||||
private prefillForEdit() {
|
private prefillForEdit() {
|
||||||
const data = this.clientData;
|
const data = this.clientData;
|
||||||
|
this.loadExistingSeniorPwdDocuments();
|
||||||
|
|
||||||
// Patch all base controls that exist on the form
|
// Patch all base controls that exist on the form
|
||||||
this.createClientForm.patchValue({
|
this.createClientForm.patchValue({
|
||||||
@@ -477,11 +724,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
mobileNo: data.mobileNo,
|
mobileNo: data.mobileNo,
|
||||||
alternateMobileNo: data.alternateMobileNo || '',
|
alternateMobileNo: data.alternateMobileNo || '',
|
||||||
emailAddress: data.emailAddress,
|
emailAddress: data.emailAddress,
|
||||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : '',
|
dateOfBirth: data.dateOfBirth
|
||||||
|
? Array.isArray(data.dateOfBirth)
|
||||||
|
? new Date(data.dateOfBirth[0], data.dateOfBirth[1] - 1, data.dateOfBirth[2])
|
||||||
|
: new Date(data.dateOfBirth)
|
||||||
|
: '',
|
||||||
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
||||||
occupationId: data.occupation?.id || data.occupationId || '',
|
occupationId: data.occupation?.id || data.occupationId || '',
|
||||||
monthlyIncome: data.monthlyIncome || '',
|
monthlyIncome: data.monthlyIncome || '',
|
||||||
|
annualIncome: data.annualIncome || '',
|
||||||
annualTurnover: data.annualTurnover || '',
|
annualTurnover: data.annualTurnover || '',
|
||||||
|
monthlyTurnover: data.monthlyTurnover || '',
|
||||||
|
isPwd: data.isPwd || false,
|
||||||
|
pwdCategoryId: data.pwdCategory?.id || '',
|
||||||
agricultureIncome: data.agricultureIncome || '',
|
agricultureIncome: data.agricultureIncome || '',
|
||||||
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
||||||
submittedOnDate: data.timeline?.submittedOnDate
|
submittedOnDate: data.timeline?.submittedOnDate
|
||||||
@@ -555,6 +810,42 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore linked guardian for minor clients. The `guardianDetails` control group above was
|
||||||
|
// just created synchronously by the dateOfBirth subscription (patched a few lines up), which
|
||||||
|
// also resets `selectedGuardian` to null — so this has to run after that patch, not before it.
|
||||||
|
// If a guardian is linked in the database (or the client is marked minor), we force minor state
|
||||||
|
// and explicitly ensure the form control exists so the guardian details load correctly.
|
||||||
|
if ((data.isMinor || this.isMinor || data.guardianClientId) && data.guardianClientId) {
|
||||||
|
this.isMinor = true;
|
||||||
|
if (!this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.addControl(
|
||||||
|
'guardianDetails',
|
||||||
|
this.formBuilder.group({
|
||||||
|
guardianClientId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
guardianRelation: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
||||||
|
guardianClientId: data.guardianClientId,
|
||||||
|
guardianRelation: data.guardianRelation || ''
|
||||||
|
});
|
||||||
|
this.selectedGuardian = {
|
||||||
|
id: data.guardianClientId,
|
||||||
|
displayName: data.guardianClientDisplayName || '',
|
||||||
|
_loading: true
|
||||||
|
};
|
||||||
|
this.fetchClientFullDetails(data.guardianClientId, this.selectedGuardian);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,6 +912,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
annualIncome: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
annualTurnover: [
|
annualTurnover: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -628,6 +926,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
monthlyTurnover: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
isPwd: [false],
|
||||||
|
pwdCategoryId: [''],
|
||||||
agricultureIncome: [
|
agricultureIncome: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -665,6 +972,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
||||||
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
||||||
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
||||||
|
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -780,7 +1088,8 @@ 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.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
||||||
this.ageBreakdown = { years, months, days };
|
this.ageBreakdown = { years, months, days };
|
||||||
const wasMinor = this.isMinor;
|
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
|
// Add/remove guardian form group based on minor status
|
||||||
if (this.isMinor && !wasMinor) {
|
if (this.isMinor && !wasMinor) {
|
||||||
this.createClientForm.addControl(
|
this.createClientForm.addControl(
|
||||||
@@ -806,12 +1115,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
@@ -853,6 +1164,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.mobileVerified = false;
|
this.mobileVerified = false;
|
||||||
|
// Also reset create-time contact-OTP verification — an OTP verified against a
|
||||||
|
// previous mobile number no longer applies once the value is edited. Belt-and-braces
|
||||||
|
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
|
||||||
|
// if the OTP block was unmounted (e.g. `*ngIf` hid it while the field was momentarily
|
||||||
|
// invalid) and its own reset never had a chance to run.
|
||||||
|
this.mobileOtpVerified = false;
|
||||||
|
this.mobileNewOtpId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.createClientForm
|
this.createClientForm
|
||||||
@@ -861,6 +1179,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.alternateMobileVerified = false;
|
this.alternateMobileVerified = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset create-time contact-OTP verification when email address changes — see the
|
||||||
|
// mobileNo subscription above for the rationale.
|
||||||
|
this.createClientForm
|
||||||
|
.get('emailAddress')
|
||||||
|
.valueChanges.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.emailOtpVerified = false;
|
||||||
|
this.emailNewOtpId = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges) {
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
@@ -1487,6 +1815,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
||||||
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
||||||
return 'undergraduate';
|
return 'undergraduate';
|
||||||
|
// Diploma is its own Education Level, distinct from School — checked before the school
|
||||||
|
// keywords below so it doesn't get swept into that bucket.
|
||||||
|
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
|
||||||
if (
|
if (
|
||||||
n.includes('school') ||
|
n.includes('school') ||
|
||||||
n.includes('10th') ||
|
n.includes('10th') ||
|
||||||
@@ -1495,7 +1826,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
n.includes('hsc') ||
|
n.includes('hsc') ||
|
||||||
n.includes('matric') ||
|
n.includes('matric') ||
|
||||||
n.includes('intermediate') ||
|
n.includes('intermediate') ||
|
||||||
n.includes('diploma') ||
|
|
||||||
n.includes('iti') ||
|
n.includes('iti') ||
|
||||||
n.includes('below')
|
n.includes('below')
|
||||||
)
|
)
|
||||||
@@ -1573,8 +1903,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Degrees filtered by the currently selected Education Level.
|
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
|
||||||
* Returns all degrees if no level is selected, or category can't be determined.
|
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
|
||||||
|
* match on that authoritative field first. Only fall back to the name-keyword heuristic
|
||||||
|
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
|
||||||
|
* degrees never silently disappear because an admin added one without a description.
|
||||||
|
* Returns all degrees if no level is selected.
|
||||||
*/
|
*/
|
||||||
get filteredDegreeQualificationOptions(): any[] {
|
get filteredDegreeQualificationOptions(): any[] {
|
||||||
const level = this.getSelectedEducationLevel();
|
const level = this.getSelectedEducationLevel();
|
||||||
@@ -1582,6 +1916,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const category = this.getEducationLevelCategory(level.name);
|
const category = this.getEducationLevelCategory(level.name);
|
||||||
if (!category) return this.degreeQualificationOptions;
|
if (!category) return this.degreeQualificationOptions;
|
||||||
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
||||||
|
if (d.description) {
|
||||||
|
return this.getEducationLevelCategory(d.description) === category;
|
||||||
|
}
|
||||||
const c = this.getDegreeQualificationCategory(d.name);
|
const c = this.getDegreeQualificationCategory(d.name);
|
||||||
return c === category || c === '';
|
return c === category || c === '';
|
||||||
});
|
});
|
||||||
@@ -1744,6 +2081,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// Store extended fields separately (not yet supported by Fineract API)
|
// Store extended fields separately (not yet supported by Fineract API)
|
||||||
// Add isMinor flag
|
// Add isMinor flag
|
||||||
generalDetails.isMinor = this.isMinor;
|
generalDetails.isMinor = this.isMinor;
|
||||||
|
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
|
||||||
|
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
|
||||||
|
|
||||||
// Add guardian clientId for minors
|
// Add guardian clientId for minors
|
||||||
if (this.isMinor && generalDetails.guardianDetails) {
|
if (this.isMinor && generalDetails.guardianDetails) {
|
||||||
@@ -1794,6 +2133,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
return generalDetails;
|
return generalDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get calculatedRisk(): {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} {
|
||||||
|
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
||||||
|
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
||||||
|
const occId = this.createClientForm.get('occupationCvId')?.value;
|
||||||
|
|
||||||
|
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
|
||||||
|
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
|
||||||
|
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
|
||||||
|
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
|
||||||
|
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
|
||||||
|
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
|
||||||
|
|
||||||
|
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
|
||||||
|
|
||||||
|
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||||
|
if (this.riskRules && this.riskRules.length > 0) {
|
||||||
|
for (const rule of this.riskRules) {
|
||||||
|
// Skip transaction/AML rules that have no KYC criteria
|
||||||
|
if (
|
||||||
|
rule.legalFormId == null &&
|
||||||
|
rule.occupationCategoryCvId == null &&
|
||||||
|
rule.occupationCvId == null &&
|
||||||
|
rule.minMonthlyIncome == null &&
|
||||||
|
rule.maxMonthlyIncome == null &&
|
||||||
|
rule.minAnnualIncome == null &&
|
||||||
|
rule.maxAnnualIncome == null &&
|
||||||
|
rule.minAnnualTurnover == null &&
|
||||||
|
rule.maxAnnualTurnover == null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match legal form
|
||||||
|
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation category
|
||||||
|
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation
|
||||||
|
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match monthly income ranges
|
||||||
|
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||||
|
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match turnover ranges
|
||||||
|
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All matched!
|
||||||
|
return {
|
||||||
|
category: rule.riskCategory,
|
||||||
|
reason: rule.reason,
|
||||||
|
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
|
||||||
|
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
||||||
|
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
||||||
|
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
||||||
|
dormantFlag: rule.dormantFlag || 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Fallbacks
|
||||||
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
|
return {
|
||||||
|
category: 2,
|
||||||
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category: 1,
|
||||||
|
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+35
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<strong>UN Security Council</strong> &
|
<strong>UN Security Council</strong> &
|
||||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && !matchFound && !errorOccurred) {
|
@if (!verifying && !matchFound && !errorOccurred) {
|
||||||
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && matchFound) {
|
@if (!verifying && matchFound) {
|
||||||
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="escalate-banner">
|
<div class="escalate-banner">
|
||||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && errorOccurred) {
|
@if (!verifying && errorOccurred) {
|
||||||
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regulatory-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
.approve-card {
|
.approve-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
errorOccurred = false;
|
errorOccurred = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
|
listDataAsOf: string | null = null;
|
||||||
|
|
||||||
|
regulatoryBasis =
|
||||||
|
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||||
|
|
||||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
progressValue = 0;
|
progressValue = 0;
|
||||||
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
'Unable to connect to sanctions screening service.';
|
'Unable to connect to sanctions screening service.';
|
||||||
} else {
|
} else {
|
||||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||||
|
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||||
|
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (this.apiResult?.matchFound) {
|
if (this.apiResult?.matchFound) {
|
||||||
this.matchFound = true;
|
this.matchFound = true;
|
||||||
this.matchCount = this.apiResult.matchCount || 0;
|
this.matchCount = this.apiResult.matchCount || 0;
|
||||||
|
|||||||
+3
-2
@@ -173,8 +173,9 @@
|
|||||||
@if (selectedAddrProof.requiresDate) {
|
@if (selectedAddrProof.requiresDate) {
|
||||||
<mat-form-field appearance="outline" class="addr-ff">
|
<mat-form-field appearance="outline" class="addr-ff">
|
||||||
<mat-label>Issue / bill date</mat-label>
|
<mat-label>Issue / bill date</mat-label>
|
||||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||||
<input matInput type="date" formControlName="issueDate" />
|
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+114
-25
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
|||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-india-address-step',
|
selector: 'mifosx-client-india-address-step',
|
||||||
@@ -38,11 +41,20 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private kycUpload = inject(KycUploadHelperService);
|
private kycUpload = inject(KycUploadHelperService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private dates = inject(Dates);
|
||||||
|
|
||||||
/** Client ID — when set, loads existing addresses for edit mode */
|
/** Client ID — when set, loads existing addresses for edit mode */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||||
|
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||||
|
@Input() isMinor = false;
|
||||||
|
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||||
|
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||||
|
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||||
|
@Input() isNonIndividual = false;
|
||||||
|
|
||||||
activeTab = 0;
|
activeTab = 0;
|
||||||
sameAsPermanent = false;
|
sameAsPermanent = false;
|
||||||
@@ -235,12 +247,50 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient address-tab state for a brand-new client.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session — the previous client's address forms,
|
||||||
|
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
|
||||||
|
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.permanentForm.reset({ country: 'India' });
|
||||||
|
this.currentForm.reset({ country: 'India' });
|
||||||
|
this.officeForm.reset({ country: 'India' });
|
||||||
|
this.correspondenceForm.reset({ country: 'India' });
|
||||||
|
this.activeTab = 0;
|
||||||
|
this.sameAsPermanent = false;
|
||||||
|
this.pinStatus = {
|
||||||
|
permanent: { loading: false, found: false, error: false, message: '' },
|
||||||
|
current: { loading: false, found: false, error: false, message: '' },
|
||||||
|
office: { loading: false, found: false, error: false, message: '' },
|
||||||
|
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||||
|
};
|
||||||
|
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.addrFiles = { front: null, back: null };
|
||||||
|
this.addrMergedFile = null;
|
||||||
|
this.addrMergedPreview = null;
|
||||||
|
this.existingAddressProofIdentifierId = null;
|
||||||
|
this.existingAddressProofDocuments = [];
|
||||||
|
this.addressLoaded = false;
|
||||||
|
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
||||||
|
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
|
||||||
|
// that reduced list again can never recover the items it already removed.
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
|
|
||||||
private loadProofTypes(): void {
|
private loadProofTypes(): void {
|
||||||
this.loadingProofs = true;
|
this.loadingProofs = true;
|
||||||
this.clientsService.getKycProofTypes().subscribe({
|
this.clientsService.getKycProofTypes().subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
const activeFilter = (p: any) => p?.active !== false;
|
const activeFilter = (p: any) => p?.active !== false;
|
||||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||||
|
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||||
|
);
|
||||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
this.loadingProofs = false;
|
this.loadingProofs = false;
|
||||||
},
|
},
|
||||||
@@ -250,6 +300,41 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts,
|
||||||
|
* employer letters or pension orders in their own name, and Voter ID/Driving Licence both
|
||||||
|
* require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */
|
||||||
|
private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isMinor) return proofs;
|
||||||
|
return proofs.filter((p) =>
|
||||||
|
ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||||
|
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT',
|
||||||
|
'VOTER_ID',
|
||||||
|
'DRIVING_LICENSE',
|
||||||
|
'NREGA_JOB_CARD',
|
||||||
|
'NPR_LETTER',
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isNonIndividual) return proofs;
|
||||||
|
return proofs.filter(
|
||||||
|
(p) =>
|
||||||
|
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
selectAddrProof(proof: ProofType): void {
|
selectAddrProof(proof: ProofType): void {
|
||||||
this.selectedAddrProof = proof;
|
this.selectedAddrProof = proof;
|
||||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||||
@@ -330,11 +415,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||||
return {
|
return {
|
||||||
proof: this.selectedAddrProof,
|
proof: this.selectedAddrProof,
|
||||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||||
files
|
files
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -355,6 +441,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) {
|
||||||
|
const filtered = this.filterAddressProofsForMinor(this.addressProofs);
|
||||||
|
if (filtered.length !== this.addressProofs.length) {
|
||||||
|
this.addressProofs = filtered;
|
||||||
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
|
if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||||
|
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||||
|
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,13 +563,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
proofId: matchedProof.id,
|
proofId: matchedProof.id,
|
||||||
documentKey: identifier.documentKey || ''
|
documentKey: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||||
// shows what was previously entered.
|
// shows what was previously entered.
|
||||||
const desc: string = identifier.description || '';
|
const desc: string = identifier.description || '';
|
||||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||||
if (issueMatch) {
|
if (issueMatch) {
|
||||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,33 +671,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved address proof in an in-app preview dialog. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
viewExistingAddressProof(): void {
|
viewExistingAddressProof(): void {
|
||||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingAddressProofDocuments[0];
|
const doc = this.existingAddressProofDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
// Silent fail — UI shows the saved name so user knows it exists.
|
// Silent fail — UI shows the saved name so user knows it exists.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,33 +28,36 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||||
@if (!isNonIndividual) {
|
@if (!isNonIndividual) {
|
||||||
@if (readOnly) {
|
<!-- Minors don't hold a PAN card — skip the PAN checkbox/section entirely -->
|
||||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
@if (!isMinor) {
|
||||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
@if (readOnly) {
|
||||||
<div class="form-field checkbox-field full-width">
|
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||||
<div class="ro-flag">
|
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||||
<span class="ro-flag__label">Client has PAN Card</span>
|
<div class="form-field checkbox-field full-width">
|
||||||
@if (kycForm.get('hasPan')?.value) {
|
<div class="ro-flag">
|
||||||
<span class="ro-flag__chip ro-flag__chip--yes">
|
<span class="ro-flag__label">Client has PAN Card</span>
|
||||||
<mat-icon>check_circle</mat-icon>
|
@if (kycForm.get('hasPan')?.value) {
|
||||||
Yes
|
<span class="ro-flag__chip ro-flag__chip--yes">
|
||||||
</span>
|
<mat-icon>check_circle</mat-icon>
|
||||||
} @else {
|
Yes
|
||||||
<span class="ro-flag__chip ro-flag__chip--no">
|
</span>
|
||||||
<mat-icon>cancel</mat-icon>
|
} @else {
|
||||||
No
|
<span class="ro-flag__chip ro-flag__chip--no">
|
||||||
</span>
|
<mat-icon>cancel</mat-icon>
|
||||||
}
|
No
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
} @else {
|
<div class="form-field checkbox-field full-width">
|
||||||
<div class="form-field checkbox-field full-width">
|
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Individual PAN with inline verify -->
|
<!-- Individual PAN with inline verify -->
|
||||||
@if (kycForm.get('hasPan')?.value) {
|
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||||
<div class="form-field verify-field">
|
<div class="form-field verify-field">
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
<mat-label>PAN Number</mat-label>
|
<mat-label>PAN Number</mat-label>
|
||||||
@@ -158,6 +161,17 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="change-number-btn"
|
||||||
|
[disabled]="readOnly"
|
||||||
|
(click)="changeAadhaarNumber()"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Change Number
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1049,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated Risk Category
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Risk Category</mat-label>
|
<mat-label>Risk Category</mat-label>
|
||||||
<mat-select formControlName="riskCategory">
|
<mat-select formControlName="riskCategory">
|
||||||
@@ -1072,6 +1092,64 @@
|
|||||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
|
||||||
|
<div
|
||||||
|
class="risk-auto-calc-info full-width"
|
||||||
|
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
|
||||||
|
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
|
||||||
|
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
|
||||||
|
>
|
||||||
|
<mat-icon class="info-icon">info</mat-icon>
|
||||||
|
<span class="info-text"
|
||||||
|
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="risk-rules-toggle-wrapper full-width">
|
||||||
|
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
|
||||||
|
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{
|
||||||
|
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showRiskRulesTable) {
|
||||||
|
<div class="risk-rules-card mat-elevation-z1 full-width">
|
||||||
|
<table class="risk-rules-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Risk Category</th>
|
||||||
|
<th>Primary Criteria / Basis</th>
|
||||||
|
<th>Examples / Thresholds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="rule-row rule-row--high">
|
||||||
|
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
|
||||||
|
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
|
||||||
|
<td>
|
||||||
|
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--medium">
|
||||||
|
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
|
||||||
|
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
|
||||||
|
<td>
|
||||||
|
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--low">
|
||||||
|
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
|
||||||
|
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
|
||||||
|
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field full-width">
|
<mat-form-field appearance="outline" class="form-field full-width">
|
||||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1173,6 +1251,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated AML & Compliance Checks
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>AML Check Status</mat-label>
|
<mat-label>AML Check Status</mat-label>
|
||||||
<mat-select required formControlName="amlCheckStatus">
|
<mat-select required formControlName="amlCheckStatus">
|
||||||
@@ -1266,11 +1350,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||||
Form 60 / 61
|
Form 97 / 98
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||||
}
|
}
|
||||||
@@ -1278,14 +1362,14 @@
|
|||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<div class="kyc-info-hint warning-hint">
|
<div class="kyc-info-hint warning-hint">
|
||||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||||
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
|
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form60Type">
|
<mat-select formControlName="form60Type">
|
||||||
<mat-option value="FORM_60">Form 60</mat-option>
|
<mat-option value="FORM_60">Form 97</mat-option>
|
||||||
<mat-option value="FORM_61">Form 61</mat-option>
|
<mat-option value="FORM_61">Form 98</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||||
@@ -1296,26 +1380,26 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
|
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||||
Form 15G / 15H
|
Form 121
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field checkbox-field">
|
<div class="form-field checkbox-field">
|
||||||
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
|
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
@if (kycForm.get('form15Applicable')?.value) {
|
@if (kycForm.get('form15Applicable')?.value) {
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form15Type">
|
<mat-select formControlName="form15Type">
|
||||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1359,10 +1443,15 @@
|
|||||||
<span class="upload-box__req">*</span>
|
<span class="upload-box__req">*</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||||
<label class="upload-box__btn">
|
<div class="upload-box__btn-row">
|
||||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
<label class="upload-box__btn">
|
||||||
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||||
</label>
|
<fa-icon icon="camera"></fa-icon> Choose & Crop
|
||||||
|
</label>
|
||||||
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamPhoto()">
|
||||||
|
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
<span class="upload-box__error">Photograph is required</span>
|
<span class="upload-box__error">Photograph is required</span>
|
||||||
}
|
}
|
||||||
@@ -1393,10 +1482,15 @@
|
|||||||
<span class="upload-box__req">*</span>
|
<span class="upload-box__req">*</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||||
<label class="upload-box__btn">
|
<div class="upload-box__btn-row">
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<label class="upload-box__btn">
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
</label>
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
|
</label>
|
||||||
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
<span class="upload-box__error">Signature is required</span>
|
<span class="upload-box__error">Signature is required</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,23 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.change-number-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 0 14px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.verify-chip {
|
.verify-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -297,8 +314,24 @@
|
|||||||
.toggle-vis-btn {
|
.toggle-vis-btn {
|
||||||
width: 28px !important;
|
width: 28px !important;
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
|
padding: 0 !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
|
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||||
|
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||||
|
shrink that touch-target/ripple with it, so — since the suffix
|
||||||
|
container doesn't clip overflow — it visually spills out past the
|
||||||
|
field's own border, making the icon look misplaced/half-outside the box. */
|
||||||
|
::ng-deep .mat-mdc-button-touch-target {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||||
|
::ng-deep .mat-ripple {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verification Info Banner */
|
/* Verification Info Banner */
|
||||||
@@ -1057,9 +1090,17 @@
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__btn-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d0d5dd;
|
border: 1px solid #d0d5dd;
|
||||||
@@ -1074,6 +1115,10 @@
|
|||||||
border-color: #1353a0;
|
border-color: #1353a0;
|
||||||
color: #1353a0;
|
color: #1353a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__status {
|
&__status {
|
||||||
@@ -2228,3 +2273,161 @@
|
|||||||
width: 95px;
|
width: 95px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.override-checkbox-wrapper {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-auto-calc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 18px !important;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #065f46;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-toggle-wrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.rules-toggle-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e6bb8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background: #f1f5f9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
border-bottom: 2px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row {
|
||||||
|
transition: background 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #16a34a;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import { SettingsService } from 'app/settings/settings.service';
|
|||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
|
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||||
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-kyc-step',
|
selector: 'mifosx-client-kyc-step',
|
||||||
@@ -59,12 +63,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Set by parent after client is created */
|
/** Set by parent after client is created */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
@Input() isNonIndividual = false;
|
@Input() isNonIndividual = false;
|
||||||
|
/** Minor clients don't hold a PAN card — hides the PAN section on the individual KYC form */
|
||||||
|
@Input() isMinor = false;
|
||||||
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
||||||
@Input() constitutionMeta: any = null;
|
@Input() constitutionMeta: any = null;
|
||||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||||
@Input() entityName: string = '';
|
@Input() entityName: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
@Input() calculatedRisk: {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
@@ -128,6 +143,31 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
markPhotoSigTouched(): void {
|
markPhotoSigTouched(): void {
|
||||||
this.photoSigTouched = true;
|
this.photoSigTouched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable label for each `kycForm` control that can carry a `required` error —
|
||||||
|
* used to build a specific "why is Save & Next disabled" message instead of a generic one.
|
||||||
|
* Controls not listed here (e.g. purely pattern-validated ones like panNumber/gstinNumber)
|
||||||
|
* only block when the user has actually typed something invalid, which is covered separately. */
|
||||||
|
private static readonly REQUIRED_FIELD_LABELS: Record<string, string> = {
|
||||||
|
kycDocumentName: 'KYC Document Type',
|
||||||
|
kycDocumentNumber: 'KYC Document Number',
|
||||||
|
amlCheckStatus: 'AML Check Status',
|
||||||
|
bannedEntityMatch: 'Banned Entity List Match'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||||
|
* that fails its pattern) in plain English — surfaced via the Save & Next tooltip so staff
|
||||||
|
* aren't left staring at a greyed-out button with no clue what's still missing. Whether or
|
||||||
|
* not photo/signature previews are filled has no bearing on this list (they aren't part of
|
||||||
|
* `kycForm` at all — see `photoSigComplete`). */
|
||||||
|
get invalidFieldLabels(): string[] {
|
||||||
|
if (!this.kycForm) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(this.kycForm.controls)
|
||||||
|
.filter((key) => this.kycForm.get(key)?.invalid)
|
||||||
|
.map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key);
|
||||||
|
}
|
||||||
/** Fallback document types when template API is unavailable */
|
/** Fallback document types when template API is unavailable */
|
||||||
private defaultDocumentTypes = [
|
private defaultDocumentTypes = [
|
||||||
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
{ name: 'Aadhaar Card', code: 'AADHAAR' },
|
||||||
@@ -136,7 +176,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
||||||
{ name: 'Voter ID', code: 'VOTER_ID' },
|
{ name: 'Voter ID', code: 'VOTER_ID' },
|
||||||
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
||||||
{ name: 'National Population Register Letter', code: 'NPR_LETTER' }
|
{ name: 'National Population Register Letter', code: 'NPR_LETTER' },
|
||||||
|
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||||
|
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||||
|
];
|
||||||
|
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||||
|
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||||
|
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||||
|
private nonIndividualDefaultDocTypes = [
|
||||||
|
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||||
|
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||||
|
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||||
|
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||||
|
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||||
];
|
];
|
||||||
/** Constitution-based KYC document types for non-individual clients */
|
/** Constitution-based KYC document types for non-individual clients */
|
||||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||||
@@ -218,6 +271,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
riskCalculationReason = '';
|
||||||
|
showRiskRulesTable = false;
|
||||||
|
|
||||||
/** PAN verification */
|
/** PAN verification */
|
||||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||||
panVerifying = false;
|
panVerifying = false;
|
||||||
@@ -409,6 +465,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocIssueDate: [''],
|
kycDocIssueDate: [''],
|
||||||
kycDocExpiryDate: [''],
|
kycDocExpiryDate: [''],
|
||||||
riskCategory: [1],
|
riskCategory: [1],
|
||||||
|
allowRiskOverride: [false],
|
||||||
|
allowAmlOverride: [false],
|
||||||
remarks: [''],
|
remarks: [''],
|
||||||
amlCheckStatus: [
|
amlCheckStatus: [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
@@ -431,22 +489,177 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15FY: ['']
|
form15FY: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
|
||||||
// Seed defaults immediately so the picker has options before clientId is set
|
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
|
||||||
// (template API requires clientId; user can still pick a type pre-save).
|
* all require adult eligibility; National Population Register Letter is deliberately
|
||||||
if (!this.kycDocumentTypes.length) {
|
* excluded too (not offered as a minor OVD in this system). */
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
|
||||||
name: d.name,
|
'AADHAAR',
|
||||||
code: d.code,
|
'PASSPORT',
|
||||||
mandatory: false
|
'BIRTH_CERTIFICATE',
|
||||||
}));
|
'SCHOOL_ID_CARD'
|
||||||
this.rebuildKycDocPicker();
|
]);
|
||||||
|
|
||||||
|
/** Birth Certificate / School ID Card are minor-specific OVDs — not relevant once the
|
||||||
|
* client is an adult, so they're hidden the other direction. */
|
||||||
|
private static readonly MINOR_ONLY_DOC_CODES = new Set([
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterDocTypesForMinor<T extends { name: string; code?: string }>(docs: T[]): T[] {
|
||||||
|
if (this.isMinor) {
|
||||||
|
return docs.filter((d) => ClientKycStepComponent.MINOR_ELIGIBLE_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
}
|
}
|
||||||
|
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||||
|
* ever applies to individuals. */
|
||||||
|
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||||
|
return this.isNonIndividual
|
||||||
|
? this.nonIndividualDefaultDocTypes
|
||||||
|
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
this.loadExistingKycIfNeeded();
|
this.loadExistingKycIfNeeded();
|
||||||
this.loadKycTemplate();
|
this.loadKycTemplate();
|
||||||
if (this.isNonIndividual && this.constitutionMeta) {
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
this.updateDocTypesForConstitution();
|
this.updateDocTypesForConstitution();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
riskControl?.enable();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
riskControl?.disable();
|
||||||
|
if (this.calculatedRisk) {
|
||||||
|
riskControl?.setValue(this.calculatedRisk.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const amlControls = [
|
||||||
|
this.kycForm.get('amlCheckStatus'),
|
||||||
|
this.kycForm.get('bannedEntityMatch'),
|
||||||
|
this.kycForm.get('willfulDefaulter'),
|
||||||
|
this.kycForm.get('ecgcSalFlag'),
|
||||||
|
this.kycForm.get('dormantFlag')
|
||||||
|
];
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.enable());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.disable());
|
||||||
|
this.applyAutoCalculatedAmlFlags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||||
|
this.kycForm.get('dormantFlag')?.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
|
* filtered by the current isMinor input for individuals). Only seeds when the list is
|
||||||
|
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
|
||||||
|
private seedDefaultDocTypes(): void {
|
||||||
|
if (this.kycDocumentTypes.length) return;
|
||||||
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: false
|
||||||
|
}));
|
||||||
|
this.rebuildKycDocPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient KYC-tab state for a brand-new client.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session. Without this, the document-type picker
|
||||||
|
* (and any PAN/Aadhaar verification state, uploaded photo/signature) would otherwise leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.kycForm.reset({
|
||||||
|
hasPan: true,
|
||||||
|
riskCategory: 1,
|
||||||
|
allowRiskOverride: false,
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE',
|
||||||
|
accountStatus: 'ACTIVE',
|
||||||
|
form60Type: 'FORM_60',
|
||||||
|
form15Type: '15G'
|
||||||
|
});
|
||||||
|
this.kycId = '';
|
||||||
|
this.kycLoaded = false;
|
||||||
|
this.kycStatus = 0;
|
||||||
|
this.kycTemplate = null;
|
||||||
|
this.kycTemplateLoaded = false;
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycDocFiles = { front: null, back: null };
|
||||||
|
this.kycDocMergedFile = null;
|
||||||
|
this.kycDocMergedPreview = null;
|
||||||
|
this.existingKycIdentifierId = null;
|
||||||
|
this.riskCalculationReason = '';
|
||||||
|
this.showRiskRulesTable = false;
|
||||||
|
this.existingKycDocuments = [];
|
||||||
|
this.photoSigTouched = false;
|
||||||
|
this.panVerifyStatus = 'NONE';
|
||||||
|
this.panVerifying = false;
|
||||||
|
this.panVerifyError = '';
|
||||||
|
this.panVerifyName = '';
|
||||||
|
this.panVerifiedOn = '';
|
||||||
|
this.aadhaarVerifyStatus = 'NONE';
|
||||||
|
this.aadhaarVerifying = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarVerifyName = '';
|
||||||
|
this.aadhaarVisible = false;
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.panAadhaarSeedingStatus = '';
|
||||||
|
this.entPanVerifyStatus = 'NONE';
|
||||||
|
this.entPanVerifying = false;
|
||||||
|
this.entPanVerifyName = '';
|
||||||
|
this.entPanVerifiedOn = '';
|
||||||
|
this.gstinVerifyStatus = 'NONE';
|
||||||
|
this.gstinVerifying = false;
|
||||||
|
this.gstinVerifyName = '';
|
||||||
|
this.gstinVerifiedOn = '';
|
||||||
|
this.gstinVerifyState = '';
|
||||||
|
this.cinVerifyStatus = 'NONE';
|
||||||
|
this.cinVerifying = false;
|
||||||
|
this.cinVerifiedOn = '';
|
||||||
|
this.cinCompanyName = '';
|
||||||
|
this.photographFile = null;
|
||||||
|
this.photographPreview = null;
|
||||||
|
this.signatureFile = null;
|
||||||
|
this.signaturePreview = null;
|
||||||
|
this.existingSignatureDocId = '';
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -464,11 +677,75 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycForm.disable({ emitEvent: false });
|
this.kycForm.disable({ emitEvent: false });
|
||||||
} else {
|
} else {
|
||||||
this.kycForm.enable({ emitEvent: false });
|
this.kycForm.enable({ emitEvent: false });
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['calculatedRisk']) {
|
||||||
|
const newRisk = changes['calculatedRisk'].currentValue;
|
||||||
|
if (newRisk) {
|
||||||
|
this.riskCalculationReason = newRisk.reason;
|
||||||
|
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
|
||||||
|
riskControl.setValue(newRisk.category);
|
||||||
|
riskControl.markAsDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
|
||||||
|
if (!allowAmlOverride) {
|
||||||
|
this.applyAutoCalculatedAmlFlags(newRisk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.riskCalculationReason = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||||
this.updateDocTypesForConstitution();
|
this.updateDocTypesForConstitution();
|
||||||
}
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance (route-reuse keeps this
|
||||||
|
// component alive across create sessions) — the individual OVD list and the entity RBI
|
||||||
|
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
|
this.updateDocTypesForConstitution();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['isMinor'] && !this.isNonIndividual) {
|
||||||
|
if (this.isMinor) {
|
||||||
|
this.kycForm.get('hasPan')?.setValue(false);
|
||||||
|
this.kycForm.get('panNumber')?.setValue('');
|
||||||
|
}
|
||||||
|
// Re-derive from the full default list, not by re-filtering the already-narrowed
|
||||||
|
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
|
||||||
|
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
|
||||||
|
// School ID Card, re-filtering that same shortened list can never bring them back once
|
||||||
|
// the client turns out to be a minor. Once the backend KYC template has loaded
|
||||||
|
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
|
||||||
|
if (!this.kycTemplateLoaded) {
|
||||||
|
const rebuilt = this.filterDocTypesForMinor(
|
||||||
|
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
|
||||||
|
);
|
||||||
|
this.kycDocumentTypes = rebuilt;
|
||||||
|
const currentName = this.kycForm.get('kycDocumentName')?.value;
|
||||||
|
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
}
|
||||||
|
this.rebuildKycDocPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update KYC document types based on selected constitution type */
|
/** Update KYC document types based on selected constitution type */
|
||||||
@@ -539,30 +816,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycDocMergedPreview = null;
|
this.kycDocMergedPreview = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
viewExistingKycDoc(): void {
|
viewExistingKycDoc(): void {
|
||||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingKycDocuments[0];
|
const doc = this.existingKycDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
if (w) {
|
}),
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
/* silent — UI still shows the saved filename */
|
/* silent — UI still shows the saved filename */
|
||||||
}
|
}
|
||||||
@@ -653,6 +917,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
||||||
// Risk & Remarks
|
// Risk & Remarks
|
||||||
riskCategory: data.riskCategory || 1,
|
riskCategory: data.riskCategory || 1,
|
||||||
|
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
||||||
|
allowAmlOverride: this.calculatedRisk
|
||||||
|
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
|
||||||
|
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
|
||||||
|
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
|
||||||
|
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
|
||||||
|
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
|
||||||
|
: false,
|
||||||
remarks: data.remarks || '',
|
remarks: data.remarks || '',
|
||||||
hasPan: !!data.panNumber,
|
hasPan: !!data.panNumber,
|
||||||
// AML & Compliance
|
// AML & Compliance
|
||||||
@@ -738,12 +1010,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const n = i?.documentType?.name?.toLowerCase();
|
const n = i?.documentType?.name?.toLowerCase();
|
||||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||||
};
|
};
|
||||||
|
// Re-uploading a KYC document creates a new identifier row rather than updating the
|
||||||
|
// old one in place (same pattern as OVD identifiers), so sort newest-first before
|
||||||
|
// picking — otherwise a repeat upload of the same document type would keep showing
|
||||||
|
// the ORIGINAL file forever, since .find() would always hit the oldest matching row.
|
||||||
|
const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||||
// Pick by document-type name match first; otherwise the first identifier whose
|
// Pick by document-type name match first; otherwise the first identifier whose
|
||||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||||
const match = kycDocumentName
|
const match = kycDocumentName
|
||||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||||
: null;
|
: null;
|
||||||
const identifier = match || identifiers.find(isKycType);
|
const identifier = match || sorted.find(isKycType);
|
||||||
if (!identifier?.id) return;
|
if (!identifier?.id) return;
|
||||||
|
|
||||||
this.existingKycIdentifierId = identifier.id.toString();
|
this.existingKycIdentifierId = identifier.id.toString();
|
||||||
@@ -818,7 +1095,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!this.clientId) return;
|
if (!this.clientId) return;
|
||||||
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
||||||
next: (documents: any[]) => {
|
next: (documents: any[]) => {
|
||||||
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (sigDoc?.id) {
|
if (sigDoc?.id) {
|
||||||
this.existingSignatureDocId = sigDoc.id.toString();
|
this.existingSignatureDocId = sigDoc.id.toString();
|
||||||
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
@@ -847,11 +1129,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycTemplate = template;
|
this.kycTemplate = template;
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
if (template?.requiredDocuments?.length > 0) {
|
if (template?.requiredDocuments?.length > 0) {
|
||||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||||
name: doc.name,
|
template.requiredDocuments.map((doc: any) => ({
|
||||||
code: doc.code || doc.name,
|
name: doc.name,
|
||||||
mandatory: doc.mandatory ?? false
|
code: doc.code || doc.name,
|
||||||
}));
|
mandatory: doc.mandatory ?? false
|
||||||
|
}))
|
||||||
|
);
|
||||||
// Update kycDocumentName validator based on mandatory docs
|
// Update kycDocumentName validator based on mandatory docs
|
||||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||||
if (hasMandatory) {
|
if (hasMandatory) {
|
||||||
@@ -864,7 +1148,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
error: () => {
|
error: () => {
|
||||||
// Fallback to defaults if template API unavailable
|
// Fallback to defaults if template API unavailable
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
code: d.code,
|
code: d.code,
|
||||||
mandatory: false
|
mandatory: false
|
||||||
@@ -1291,6 +1575,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lets the user go back and fix a wrong Aadhaar number after an OTP was already sent —
|
||||||
|
* unlocks the (readonly-while-OTP-pending) input and discards the in-flight OTP session,
|
||||||
|
* since it was requested for the now-being-corrected number. */
|
||||||
|
changeAadhaarNumber(): void {
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.resendCountdown = 0;
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
this.resendTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Verify Aadhaar OTP */
|
/** Verify Aadhaar OTP */
|
||||||
verifyAadhaarOtp() {
|
verifyAadhaarOtp() {
|
||||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||||
@@ -1366,6 +1665,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!input.files?.length) return;
|
if (!input.files?.length) return;
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
await this.openPhotoCropDialog(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webcam capture entry point for the profile photograph — feeds the captured frame
|
||||||
|
* through the same crop dialog + staging/upload path as the file-picker flow above,
|
||||||
|
* so behaviour (immediate upload in edit mode vs staged-until-submit in create mode)
|
||||||
|
* stays identical regardless of capture source. */
|
||||||
|
async captureWebcamPhoto(): Promise<void> {
|
||||||
|
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
||||||
|
if (!imageDataUrl) return;
|
||||||
|
const file = dataUrlToFile(imageDataUrl, 'webcam-photograph.jpg');
|
||||||
|
await this.openPhotoCropDialog(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openPhotoCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
width: '680px',
|
width: '680px',
|
||||||
@@ -1391,6 +1709,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!input.files?.length) return;
|
if (!input.files?.length) return;
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
await this.openSignatureCropDialog(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
width: '680px',
|
width: '680px',
|
||||||
@@ -1399,42 +1721,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
this.signatureFile = result.croppedFile;
|
this.applySignatureFile(result.croppedFile, result.previewUrl);
|
||||||
this.signaturePreview = result.previewUrl;
|
|
||||||
// In edit mode, delete old signature doc then upload new one
|
|
||||||
if (this.clientId) {
|
|
||||||
if (this.existingSignatureDocId) {
|
|
||||||
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload
|
||||||
|
* path as the file-picker and webcam flows, minus the crop step (the pad's canvas is
|
||||||
|
* already the right shape/size). */
|
||||||
|
drawSignature(): void {
|
||||||
|
const dialogRef = this.dialog.open(DrawSignatureDialogComponent);
|
||||||
|
dialogRef.afterClosed().subscribe((file: File) => {
|
||||||
|
if (file) {
|
||||||
|
this.applySignatureFile(file, URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stages the signature locally and, in edit mode, persists it — replacing any existing
|
||||||
|
* signature doc first so a client never ends up with two. Shared by the file-picker, webcam
|
||||||
|
* and draw-pad entry points. */
|
||||||
|
private applySignatureFile(file: File, previewUrl: string): void {
|
||||||
|
this.signatureFile = file;
|
||||||
|
this.signaturePreview = previewUrl;
|
||||||
|
if (!this.clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.existingSignatureDocId) {
|
||||||
|
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
|
next: () => this.uploadSignatureFile(file),
|
||||||
|
error: () => this.uploadSignatureFile(file)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.uploadSignatureFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uploadSignatureFile(file: File): void {
|
||||||
|
this.clientsService.uploadClientSignatureImage(this.clientId, file).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
removePhoto() {
|
removePhoto() {
|
||||||
this.photographFile = null;
|
this.photographFile = null;
|
||||||
this.photographPreview = null;
|
this.photographPreview = null;
|
||||||
@@ -1459,7 +1790,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get kycData(): any {
|
get kycData(): any {
|
||||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
const formVal = this.kycForm.getRawValue();
|
||||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
@@ -1528,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.form15Applicable
|
v.form15Applicable
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
|
||||||
|
if (!risk) return;
|
||||||
|
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
|
||||||
|
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
|
||||||
|
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
|
||||||
|
|
||||||
|
this.kycForm.get('amlCheckStatus')?.markAsDirty();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.markAsDirty();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
|
||||||
|
this.kycForm.get('dormantFlag')?.markAsDirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@
|
|||||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
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>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add-
|
|||||||
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component';
|
||||||
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component';
|
||||||
import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component';
|
import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component';
|
||||||
|
import { HistoryTabComponent } from './clients-view/history-tab/history-tab.component';
|
||||||
import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component';
|
import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component';
|
||||||
import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component';
|
import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component';
|
||||||
import { AddressTabComponent } from './clients-view/address-tab/address-tab.component';
|
import { AddressTabComponent } from './clients-view/address-tab/address-tab.component';
|
||||||
@@ -190,7 +191,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'form60',
|
path: 'form60',
|
||||||
component: Form60TabComponent,
|
component: Form60TabComponent,
|
||||||
data: { title: 'Form 60', breadcrumb: 'Form 60', routeParamBreadcrumb: false }
|
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'documents',
|
path: 'documents',
|
||||||
@@ -208,6 +209,11 @@ const routes: Routes = [
|
|||||||
clientNotes: ClientNotesResolver
|
clientNotes: ClientNotesResolver
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
component: HistoryTabComponent,
|
||||||
|
data: { title: 'History', breadcrumb: 'History', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'datatables',
|
path: 'datatables',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document with name='clientSignature'
|
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
||||||
|
// so pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.existing.CLIENT_SIGNATURE = {
|
this.existing.CLIENT_SIGNATURE = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -410,6 +410,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
|
Welcome Kit
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -426,6 +429,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
<button mat-menu-item (click)="doAction('Upload Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||||
{{ 'labels.buttons.Upload Signature' | translate }}
|
{{ 'labels.buttons.Upload Signature' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Capture Signature')" *mifosxHasPermission="'CREATE_CLIENTIMAGE'">
|
||||||
|
Capture Signature (Webcam)
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
|
<button mat-menu-item (click)="doAction('Delete Signature')" *mifosxHasPermission="'DELETE_CLIENTIMAGE'">
|
||||||
{{ 'labels.buttons.Delete Signature' | translate }}
|
{{ 'labels.buttons.Delete Signature' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -559,6 +565,15 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
<i class="fa fa-sticky-note cv-tab-icon"></i> {{ 'labels.inputs.Notes' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./history']"
|
||||||
|
routerLinkActive
|
||||||
|
#history="routerLinkActive"
|
||||||
|
[active]="history.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-history cv-tab-icon"></i> History
|
||||||
|
</a>
|
||||||
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
@for (clientDatatable of clientDatatables; track clientDatatable) {
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
|
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../clients.service';
|
import { ClientsService } from '../clients.service';
|
||||||
@@ -240,6 +241,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Upload Signature':
|
case 'Upload Signature':
|
||||||
this.uploadSignature();
|
this.uploadSignature();
|
||||||
break;
|
break;
|
||||||
|
case 'Capture Signature':
|
||||||
|
this.captureSignatureImage();
|
||||||
|
break;
|
||||||
case 'Delete Signature':
|
case 'Delete Signature':
|
||||||
this.deleteSignature();
|
this.deleteSignature();
|
||||||
break;
|
break;
|
||||||
@@ -252,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Delete Image':
|
case 'Delete Image':
|
||||||
this.deleteProfileImage();
|
this.deleteProfileImage();
|
||||||
break;
|
break;
|
||||||
|
case 'Welcome Kit':
|
||||||
|
this.dispatchWelcomeKit();
|
||||||
|
break;
|
||||||
case 'Create Standing Instructions':
|
case 'Create Standing Instructions':
|
||||||
const createStandingInstructionsQueryParams: any = {
|
const createStandingInstructionsQueryParams: any = {
|
||||||
officeId: this.clientViewData.officeId,
|
officeId: this.clientViewData.officeId,
|
||||||
@@ -332,6 +339,8 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
this.uploadSignature();
|
this.uploadSignature();
|
||||||
} else if (response.draw) {
|
} else if (response.draw) {
|
||||||
this.drawSignature();
|
this.drawSignature();
|
||||||
|
} else if (response.capture) {
|
||||||
|
this.captureSignatureImage();
|
||||||
} else if (response.delete) {
|
} else if (response.delete) {
|
||||||
this.deleteSignature();
|
this.deleteSignature();
|
||||||
}
|
}
|
||||||
@@ -391,7 +400,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
* Captures clients profile image.
|
* Captures clients profile image.
|
||||||
*/
|
*/
|
||||||
private captureProfileImage() {
|
private captureProfileImage() {
|
||||||
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent);
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||||
if (imageURL) {
|
if (imageURL) {
|
||||||
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
||||||
@@ -401,6 +412,24 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures client signature via webcam (photograph of a paper signature).
|
||||||
|
* Mirrors `captureProfileImage()` — same dialog, parametrized for signature capture,
|
||||||
|
* uploaded via `uploadCapturedClientSignature()` instead of the profile-image upload.
|
||||||
|
*/
|
||||||
|
private captureSignatureImage() {
|
||||||
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
data: { captureTarget: 'signature' }
|
||||||
|
});
|
||||||
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
||||||
|
if (imageURL) {
|
||||||
|
this.clientsService.uploadCapturedClientSignature(this.clientViewData.id, imageURL).subscribe(() => {
|
||||||
|
this.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads the clients image.
|
* Uploads the clients image.
|
||||||
*/
|
*/
|
||||||
@@ -430,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Welcome Kit Dispatch dialog.
|
||||||
|
*/
|
||||||
|
private dispatchWelcomeKit() {
|
||||||
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||||
|
data: { clientId: this.clientViewData.id },
|
||||||
|
width: '600px'
|
||||||
|
});
|
||||||
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (response) {
|
||||||
|
// the form was successfully submitted
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -6,10 +6,24 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="layout-column gap-2px">
|
<div class="layout-column gap-2px capture-dialog">
|
||||||
<h1 mat-dialog-title align="center">{{ 'labels.heading.Capture Client Image' | translate }}</h1>
|
<h1 mat-dialog-title align="center" class="capture-title">
|
||||||
|
<fa-icon [icon]="isSignatureCapture ? 'signature' : 'camera'"></fa-icon>
|
||||||
|
<span>{{ dialogTitle }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="capture-instructions" align="center">{{ dialogInstructions }}</p>
|
||||||
|
|
||||||
<video #video class="capture-video" autoplay></video>
|
<div class="capture-frame" [class.capture-frame--signature]="isSignatureCapture">
|
||||||
|
<video
|
||||||
|
#video
|
||||||
|
class="capture-video"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
disableremoteplayback
|
||||||
|
controlslist="nodownload noremoteplayback nofullscreen"
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Using a class will break renderer changes -->
|
<!-- Using a class will break renderer changes -->
|
||||||
<canvas #canvas [ngStyle]="{ display: 'none' }"></canvas>
|
<canvas #canvas [ngStyle]="{ display: 'none' }"></canvas>
|
||||||
@@ -17,20 +31,20 @@
|
|||||||
<!-- Using a class will break renderer changes -->
|
<!-- Using a class will break renderer changes -->
|
||||||
<p #fallback [ngStyle]="{ display: 'none' }" align="center"></p>
|
<p #fallback [ngStyle]="{ display: 'none' }" align="center"></p>
|
||||||
|
|
||||||
<div class="flex-20" align="center">
|
<div class="capture-actions-row">
|
||||||
@if (!isCaptured) {
|
@if (!isCaptured) {
|
||||||
<button #captureButton mat-stroked-button color="primary" (click)="capture()">
|
<button #captureButton mat-stroked-button color="primary" class="capture-btn" (click)="capture()">
|
||||||
{{ 'labels.buttons.Capture' | translate }}
|
<fa-icon icon="camera" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Capture' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (isCaptured) {
|
@if (isCaptured) {
|
||||||
<button mat-stroked-button color="primary" (click)="recapture()">
|
<button mat-stroked-button color="primary" class="capture-btn" (click)="recapture()">
|
||||||
<fa-icon icon="undo" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Recapture' | translate }}
|
<fa-icon icon="undo" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Recapture' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end" class="capture-dialog-footer">
|
||||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
<button [disabled]="!isCaptured" mat-raised-button color="primary" [mat-dialog-close]="clientImageDataURL">
|
<button [disabled]="!isCaptured" mat-raised-button color="primary" [mat-dialog-close]="clientImageDataURL">
|
||||||
{{ 'labels.buttons.Upload' | translate }}
|
{{ 'labels.buttons.Upload' | translate }}
|
||||||
|
|||||||
+96
-6
@@ -6,13 +6,103 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@use '../../../../../assets/styles/colours' as colors;
|
/* No explicit width/maxHeight is passed at every `dialog.open(CaptureImageDialogComponent)`
|
||||||
|
call site (there are several, across clients-view/create/edit flows) — capping height and
|
||||||
|
allowing internal scroll here, once, keeps the Cancel/Upload footer reachable on short
|
||||||
|
viewports instead of relying on every caller to remember a maxHeight config. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-dialog {
|
||||||
|
padding: 4px 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1074b9;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-instructions {
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-frame {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
|
||||||
|
/* Capped in vh, not just px, so the frame shrinks on short viewports (laptop screens with
|
||||||
|
browser chrome eating into the visible height) instead of pushing the Capture/Cancel/
|
||||||
|
Upload buttons below the fold — `object-fit: cover` on the video means shrinking this box
|
||||||
|
never reintroduces pillarboxing, it just changes how much of the live frame is visible. */
|
||||||
|
max-height: 42vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 25%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
|
||||||
|
/* Default (profile) frame follows the requested 4:3 capture stream. */
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
|
||||||
|
/* Signature capture uses a shorter, wider (16:9) frame — paper signatures are landscape-ish
|
||||||
|
and this matches the wider resolution requested from getUserMedia, so the live preview
|
||||||
|
fills the frame edge-to-edge instead of pillarboxing (black bars) inside a mismatched box. */
|
||||||
|
&--signature {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 36vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.capture-video {
|
.capture-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 640px;
|
height: 100%;
|
||||||
height: auto;
|
object-fit: cover;
|
||||||
object-fit: contain;
|
display: block;
|
||||||
border-radius: 4px;
|
|
||||||
background-color: colors.$black;
|
/* Chromium auto-injects a Picture-in-Picture toggle (and sometimes a cast button) as small
|
||||||
|
square overlay icons in a bare <video>'s corners on hover — the `disablepictureinpicture`/
|
||||||
|
`disableremoteplayback` attributes stop the *feature*, but these pseudo-elements are the
|
||||||
|
belt-and-braces way to make sure the icons themselves never render. */
|
||||||
|
&::-webkit-media-controls,
|
||||||
|
&::-webkit-media-controls-panel,
|
||||||
|
&::-internal-media-controls-overlay-cast-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-btn {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
height: 42px;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-dialog-footer {
|
||||||
|
padding: 8px 4px 0 !important;
|
||||||
|
|
||||||
|
.mat-mdc-raised-button {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-6
@@ -8,11 +8,23 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
|
import { Component, ViewChild, ElementRef, AfterViewInit, Renderer2, OnDestroy, inject } from '@angular/core';
|
||||||
import { MatDialogRef, MatDialogTitle, MatDialogActions, MatDialogClose } from '@angular/material/dialog';
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
import { NgStyle } from '@angular/common';
|
import { NgStyle } from '@angular/common';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** What the captured image is for — drives the dialog's title/instructions and lets the
|
||||||
|
* caller decide (based on how it opened the dialog) which upload method to invoke on the
|
||||||
|
* resulting data URL. Defaults to 'profile' so existing callers that don't pass `data`
|
||||||
|
* keep working unchanged. */
|
||||||
|
export type CaptureImageTarget = 'profile' | 'signature';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture image dialog component
|
* Capture image dialog component
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +44,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
||||||
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<CaptureImageDialogComponent>>(MatDialogRef);
|
||||||
private renderer = inject(Renderer2);
|
private renderer = inject(Renderer2);
|
||||||
|
private data = inject<{ captureTarget?: CaptureImageTarget } | null>(MAT_DIALOG_DATA, { optional: true });
|
||||||
|
|
||||||
|
/** 'profile' (default, unchanged behaviour) or 'signature' — photographing a paper signature. */
|
||||||
|
captureTarget: CaptureImageTarget = this.data?.captureTarget || 'profile';
|
||||||
|
|
||||||
|
get isSignatureCapture(): boolean {
|
||||||
|
return this.captureTarget === 'signature';
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogTitle(): string {
|
||||||
|
return this.isSignatureCapture ? 'Capture Client Signature' : 'Capture Client Image';
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogInstructions(): string {
|
||||||
|
return this.isSignatureCapture
|
||||||
|
? 'Photograph the customer’s signed paper — line up the signature within the frame before capturing.'
|
||||||
|
: 'Line up the customer’s face within the frame before capturing.';
|
||||||
|
}
|
||||||
|
|
||||||
/** Video element reference */
|
/** Video element reference */
|
||||||
@ViewChild('video', { static: true }) video: ElementRef;
|
@ViewChild('video', { static: true }) video: ElementRef;
|
||||||
@@ -62,11 +92,12 @@ export class CaptureImageDialogComponent implements AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
startCamera() {
|
startCamera() {
|
||||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||||
const videoConstraints: MediaTrackConstraints = {
|
// Signature capture asks for a wide/short (16:9) stream to match the frame it's shown
|
||||||
width: { ideal: 640 },
|
// in — requesting an aspect ratio close to what the CSS box actually renders avoids the
|
||||||
height: { ideal: 480 },
|
// camera negotiating a 4:3 stream that then gets pillarboxed (black bars) inside a 16:9 box.
|
||||||
facingMode: 'user'
|
const videoConstraints: MediaTrackConstraints = this.isSignatureCapture
|
||||||
};
|
? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }
|
||||||
|
: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' };
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ video: videoConstraints })
|
.getUserMedia({ video: videoConstraints })
|
||||||
.then((stream: MediaStream) => {
|
.then((stream: MediaStream) => {
|
||||||
|
|||||||
+7
-1
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
|
|||||||
* @param {any} data Documents data
|
* @param {any} data Documents data
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const signature =
|
||||||
|
this.data
|
||||||
|
.filter((document: any) => document.name === 'clientSignature')
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||||
this.signatureId = signature.id;
|
this.signatureId = signature.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -347,9 +347,18 @@
|
|||||||
<i class="fa fa-calendar"></i> Issue / statement date
|
<i class="fa fa-calendar"></i> Issue / statement date
|
||||||
<span class="kyc-opt">(not older than 3 months)</span>
|
<span class="kyc-opt">(not older than 3 months)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
<input
|
||||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
class="kyc-input kyc-input--date"
|
||||||
|
[matDatepicker]="uploadIssueDatePicker"
|
||||||
|
formControlName="issueDate"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
class="kyc-input-wrap__calendar-toggle"
|
||||||
|
[for]="uploadIssueDatePicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -438,6 +447,15 @@
|
|||||||
<div class="fd-field fd-field--file">
|
<div class="fd-field fd-field--file">
|
||||||
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
<mifosx-file-upload flex="100%" (change)="onFileSelect($event)"></mifosx-file-upload>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fd-webcam-row">
|
||||||
|
<span class="fd-webcam-divider">or</span>
|
||||||
|
<button type="button" mat-stroked-button color="primary" class="fd-webcam-btn" (click)="captureFromWebcam()">
|
||||||
|
<i class="fa fa-camera"></i> Capture from Webcam
|
||||||
|
</button>
|
||||||
|
@if (capturedFileName) {
|
||||||
|
<span class="fd-webcam-confirm"><i class="fa fa-check-circle"></i> {{ capturedFileName }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+53
-28
@@ -130,6 +130,41 @@ $white: #fff;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fd-webcam-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-divider {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-btn {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-webcam-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e8e3e;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: #1e8e3e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fd-section-divider {
|
.fd-section-divider {
|
||||||
margin: 8px 0 12px;
|
margin: 8px 0 12px;
|
||||||
border-bottom: 1px solid $gray-200;
|
border-bottom: 1px solid $gray-200;
|
||||||
@@ -353,42 +388,32 @@ $red-900: #993c1d;
|
|||||||
padding-left: 38px;
|
padding-left: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date input — match styling of other kyc inputs */
|
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
|
||||||
.kyc-input--date {
|
.kyc-input--date {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding-right: 14px !important;
|
padding-left: 12px !important;
|
||||||
|
padding-right: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-calendar-picker-indicator {
|
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||||
opacity: 0.55;
|
position: absolute;
|
||||||
cursor: pointer;
|
right: 2px;
|
||||||
filter: grayscale(1);
|
top: 50%;
|
||||||
transition: opacity 0.15s;
|
transform: translateY(-50%);
|
||||||
|
color: $gray-500;
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::-webkit-calendar-picker-indicator {
|
::ng-deep .mat-mdc-icon-button svg {
|
||||||
opacity: 0.9;
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
|
|
||||||
&::-webkit-datetime-edit {
|
|
||||||
color: $gray-900;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-fields-wrapper {
|
|
||||||
color: $gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-text {
|
|
||||||
color: $gray-500;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:invalid::-webkit-datetime-edit,
|
|
||||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
|
||||||
color: $gray-500;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -16,12 +16,14 @@ import {
|
|||||||
MatDialogClose
|
MatDialogClose
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component';
|
||||||
|
import { CaptureImageDialogComponent } from '../capture-image-dialog/capture-image-dialog.component';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
import { MatIconButton } from '@angular/material/button';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { ClientsService } from '../../../clients.service';
|
import { ClientsService } from '../../../clients.service';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
|
||||||
|
import { dataUrlToFile } from '../../../../shared/utils/data-url.util';
|
||||||
|
|
||||||
type SectionKey = 'id' | 'address';
|
type SectionKey = 'id' | 'address';
|
||||||
|
|
||||||
@@ -72,6 +74,12 @@ export class UploadDocumentDialogComponent implements OnInit {
|
|||||||
addrMergedFile: File | null = null;
|
addrMergedFile: File | null = null;
|
||||||
addrMergedPreview: string | null = null;
|
addrMergedPreview: string | null = null;
|
||||||
|
|
||||||
|
/** Non-identifier (generic) mode only — set after a webcam capture, since
|
||||||
|
* `FileUploadComponent` only reflects files picked through its own native input and has
|
||||||
|
* no input for one supplied programmatically. Cleared if the user then picks a file via
|
||||||
|
* Browse instead, so only one confirmation shows at a time. */
|
||||||
|
capturedFileName: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const data = this.data;
|
const data = this.data;
|
||||||
this.documentIdentifier = !!data?.documentIdentifier;
|
this.documentIdentifier = !!data?.documentIdentifier;
|
||||||
@@ -390,6 +398,24 @@ export class UploadDocumentDialogComponent implements OnInit {
|
|||||||
if ($event.target.files.length > 0) {
|
if ($event.target.files.length > 0) {
|
||||||
const file = $event.target.files[0];
|
const file = $event.target.files[0];
|
||||||
this.uploadDocumentForm.get('file').setValue(file);
|
this.uploadDocumentForm.get('file').setValue(file);
|
||||||
|
this.capturedFileName = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Non-identifier (generic Documents tab) mode only — lets staff photograph a document
|
||||||
|
* with the webcam instead of only being able to browse for an existing file. Reuses the
|
||||||
|
* same capture dialog as the profile-photo/signature flows. */
|
||||||
|
captureFromWebcam(): void {
|
||||||
|
const ref = this.dialog.open(CaptureImageDialogComponent, {
|
||||||
|
width: '720px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
data: { captureTarget: 'profile' }
|
||||||
|
});
|
||||||
|
ref.afterClosed().subscribe((dataUrl: string | null) => {
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const file = dataUrlToFile(dataUrl, `webcam-capture-${Date.now()}.jpg`);
|
||||||
|
this.uploadDocumentForm.get('file').setValue(file);
|
||||||
|
this.capturedFileName = file.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -34,5 +34,8 @@
|
|||||||
<button mat-raised-button color="primary" [mat-dialog-close]="{ draw: true }">
|
<button mat-raised-button color="primary" [mat-dialog-close]="{ draw: true }">
|
||||||
{{ 'labels.buttons.Draw' | translate }}
|
{{ 'labels.buttons.Draw' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-raised-button color="primary" [mat-dialog-close]="{ capture: true }">
|
||||||
|
<fa-icon icon="camera"></fa-icon> Webcam
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+10
-2
@@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
/** Node Types */
|
/** Node Types */
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +40,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
CdkScrollable,
|
CdkScrollable,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose
|
MatDialogClose,
|
||||||
|
FaIconComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ViewSignatureDialogComponent implements OnInit {
|
export class ViewSignatureDialogComponent implements OnInit {
|
||||||
@@ -63,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
|
|||||||
* @param {any} data Documents data
|
* @param {any} data Documents data
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const signature = this.data.documents.find((document: any) => document.name === 'clientSignature') || {};
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const signature =
|
||||||
|
this.data.documents
|
||||||
|
.filter((document: any) => document.name === 'clientSignature')
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||||
this.signatureId = signature.id;
|
this.signatureId = signature.id;
|
||||||
this.clientId = this.data.id;
|
this.clientId = this.data.id;
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
|
||||||
|
|
||||||
|
<mat-dialog-content class="mat-typography">
|
||||||
|
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
||||||
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="!isLoading">
|
||||||
|
<!-- Tracking view if dispatches already exist -->
|
||||||
|
<div
|
||||||
|
class="dispatch-tracking-panel"
|
||||||
|
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||||
|
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
||||||
|
>
|
||||||
|
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
||||||
|
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
||||||
|
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
||||||
|
Download Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
|
||||||
|
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
|
||||||
|
<p style="margin: 4px 0">
|
||||||
|
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
|
||||||
|
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
|
||||||
|
<p style="margin: 4px 0">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||||
|
|
||||||
|
<!-- Create form if they can dispatch a new one -->
|
||||||
|
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||||
|
<h3>Create New Dispatch</h3>
|
||||||
|
<div fxLayout="column" fxLayoutGap="16px">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Delivery Address</mat-label>
|
||||||
|
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
||||||
|
Delivery Address is required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Delivery Mode</mat-label>
|
||||||
|
<mat-select formControlName="deliveryMode" required>
|
||||||
|
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
||||||
|
{{ mode }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||||
|
Delivery Mode is required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field
|
||||||
|
appearance="outline"
|
||||||
|
class="full-width"
|
||||||
|
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
||||||
|
>
|
||||||
|
<mat-label>Specify Other Mode</mat-label>
|
||||||
|
<input matInput formControlName="deliveryModeOtherText" required />
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||||
|
Please specify the delivery mode
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button mat-dialog-close>Close</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="dispatchForm.invalid || isLoading"
|
||||||
|
>
|
||||||
|
Dispatch
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
.dispatch-tracking-panel {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&.status-dispatched {
|
||||||
|
background-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-in_transit {
|
||||||
|
background-color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-delivered {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-returned {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-create-form {
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { ClientsService } from '../../../clients.service';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-welcome-kit-dialog',
|
||||||
|
templateUrl: './welcome-kit-dialog.component.html',
|
||||||
|
styleUrls: ['./welcome-kit-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
MatProgressSpinnerModule
|
||||||
|
],
|
||||||
|
providers: [DatePipe]
|
||||||
|
})
|
||||||
|
export class WelcomeKitDialogComponent implements OnInit {
|
||||||
|
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||||
|
data: { clientId: string } = inject(MAT_DIALOG_DATA);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private formBuilder = inject(FormBuilder);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private datePipe = inject(DatePipe);
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
welcomeKitsData: any = null;
|
||||||
|
dispatchForm: FormGroup = this.formBuilder.group({
|
||||||
|
deliveryAddress: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
deliveryMode: [
|
||||||
|
'POSTMAN',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
deliveryModeOtherText: ['']
|
||||||
|
});
|
||||||
|
deliveryModes = [
|
||||||
|
'POSTMAN',
|
||||||
|
'COURIER',
|
||||||
|
'OTHER'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
|
||||||
|
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
|
||||||
|
if (mode === 'OTHER') {
|
||||||
|
otherControl?.setValidators([Validators.required]);
|
||||||
|
} else {
|
||||||
|
otherControl?.clearValidators();
|
||||||
|
}
|
||||||
|
otherControl?.updateValueAndValidity();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
|
||||||
|
next: (response: any) => {
|
||||||
|
this.welcomeKitsData = response;
|
||||||
|
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
|
||||||
|
this.fetchPrimaryAddress();
|
||||||
|
} else {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPrimaryAddress() {
|
||||||
|
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||||
|
next: (addresses: any) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (addresses && addresses.length > 0) {
|
||||||
|
// Attempt to build a readable address from the first address returned
|
||||||
|
const primary = addresses[0];
|
||||||
|
const parts = [];
|
||||||
|
if (primary.addressLine1) parts.push(primary.addressLine1);
|
||||||
|
if (primary.addressLine2) parts.push(primary.addressLine2);
|
||||||
|
if (primary.city) parts.push(primary.city);
|
||||||
|
if (primary.stateName) parts.push(primary.stateName);
|
||||||
|
if (primary.postalCode) parts.push(primary.postalCode);
|
||||||
|
if (primary.countryName) parts.push(primary.countryName);
|
||||||
|
|
||||||
|
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
||||||
|
this.dispatchForm.patchValue({
|
||||||
|
deliveryAddress: clientNameStr + parts.join(', ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeDispatches() {
|
||||||
|
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||||
|
return this.welcomeKitsData.dispatches;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.dispatchForm.invalid) {
|
||||||
|
this.dispatchForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const formValue = this.dispatchForm.value;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
deliveryAddress: formValue.deliveryAddress,
|
||||||
|
deliveryMode: formValue.deliveryMode,
|
||||||
|
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||||
|
dispatchedOnDate: formattedDate,
|
||||||
|
locale: 'en',
|
||||||
|
dateFormat: 'dd MMMM yyyy'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayAddress(dispatch: any): string {
|
||||||
|
const address = dispatch.deliveryAddress || '';
|
||||||
|
const name = this.welcomeKitsData?.clientName || '';
|
||||||
|
if (name && !address.startsWith(name)) {
|
||||||
|
return `${name} — ${address}`;
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadPdf(dispatch: any) {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
|
||||||
|
const address = this.getDisplayAddress(dispatch);
|
||||||
|
const mode =
|
||||||
|
dispatch.deliveryMode === 'OTHER'
|
||||||
|
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
|
||||||
|
: dispatch.deliveryMode;
|
||||||
|
const trackingId = dispatch.trackingId || 'N/A';
|
||||||
|
const status = dispatch.status || 'N/A';
|
||||||
|
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
|
||||||
|
|
||||||
|
const primaryColor: [number, number, number] = [
|
||||||
|
31,
|
||||||
|
73,
|
||||||
|
125
|
||||||
|
]; // Navy Blue
|
||||||
|
const grayColor: [number, number, number] = [
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
const lightGray: [number, number, number] = [
|
||||||
|
230,
|
||||||
|
230,
|
||||||
|
230
|
||||||
|
];
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
|
||||||
|
// --- 1. Header Banner ---
|
||||||
|
doc.setFillColor(...primaryColor);
|
||||||
|
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||||
|
|
||||||
|
// Decorative circles
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.circle(pageWidth - 20, 15, 3, 'F');
|
||||||
|
doc.circle(pageWidth - 30, 25, 2, 'F');
|
||||||
|
doc.circle(pageWidth - 10, 30, 1.5, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(28);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('CBSNexora', 20, 22);
|
||||||
|
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Core Banking Solution', 22, 30);
|
||||||
|
|
||||||
|
// --- 2. Title ---
|
||||||
|
let currentY = 55;
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...primaryColor);
|
||||||
|
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
|
||||||
|
currentY += 12;
|
||||||
|
|
||||||
|
// --- 3. Date Line ---
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
const today = this.datePipe.transform(new Date(), 'longDate');
|
||||||
|
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
|
||||||
|
currentY += 15;
|
||||||
|
|
||||||
|
// --- 4. Greeting ---
|
||||||
|
doc.text(`Dear ${clientName},`, 20, currentY);
|
||||||
|
currentY += 10;
|
||||||
|
|
||||||
|
// --- 5. Opening welcome paragraph ---
|
||||||
|
const p1 =
|
||||||
|
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
|
||||||
|
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
|
||||||
|
doc.text(splitP1, 20, currentY);
|
||||||
|
currentY += splitP1.length * 6 + 8;
|
||||||
|
|
||||||
|
// --- 6. Short paragraph about the company ---
|
||||||
|
const p2 =
|
||||||
|
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
|
||||||
|
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
|
||||||
|
doc.text(splitP2, 20, currentY);
|
||||||
|
currentY += splitP2.length * 6 + 8;
|
||||||
|
|
||||||
|
// --- 7. Transition line before details ---
|
||||||
|
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
|
||||||
|
currentY += 8;
|
||||||
|
|
||||||
|
// --- 8. Details Section ---
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: currentY,
|
||||||
|
theme: 'plain',
|
||||||
|
margin: { left: 20, right: 20 },
|
||||||
|
styles: { fontSize: 11, cellPadding: 6, textColor: [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
] },
|
||||||
|
columnStyles: {
|
||||||
|
0: { fontStyle: 'bold', cellWidth: 50 },
|
||||||
|
1: { cellWidth: 'auto' }
|
||||||
|
},
|
||||||
|
didDrawCell: (data) => {
|
||||||
|
if (data.row.index >= 0) {
|
||||||
|
doc.setDrawColor(...lightGray);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(
|
||||||
|
data.cell.x,
|
||||||
|
data.cell.y + data.cell.height,
|
||||||
|
data.cell.x + data.cell.width,
|
||||||
|
data.cell.y + data.cell.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body: [
|
||||||
|
[
|
||||||
|
'Delivery Address',
|
||||||
|
address
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Delivery Mode',
|
||||||
|
mode
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Tracking ID',
|
||||||
|
trackingId
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Status',
|
||||||
|
status
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Dispatched On',
|
||||||
|
dispatchedOn
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 9. Closing paragraph ---
|
||||||
|
currentY = (doc as any).lastAutoTable.finalY + 12;
|
||||||
|
const p3 =
|
||||||
|
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
|
||||||
|
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
|
||||||
|
doc.text(splitP3, 20, currentY);
|
||||||
|
currentY += splitP3.length * 6 + 12;
|
||||||
|
|
||||||
|
// --- 10. Sign-off ---
|
||||||
|
doc.text('Warm regards,', 20, currentY);
|
||||||
|
currentY += 7;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('CBSNexora Operations Team', 20, currentY);
|
||||||
|
|
||||||
|
// --- 11. Footer ---
|
||||||
|
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
|
||||||
|
let footerY = currentY + 30;
|
||||||
|
if (footerY < pageHeight - 30) {
|
||||||
|
footerY = pageHeight - 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setDrawColor(...primaryColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'italic');
|
||||||
|
doc.setTextColor(...grayColor);
|
||||||
|
const timestamp = this.datePipe.transform(new Date(), 'medium');
|
||||||
|
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
|
||||||
|
|
||||||
|
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
<div class="f60-pan-ok__icon-wrap">
|
<div class="f60-pan-ok__icon-wrap">
|
||||||
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="f60-pan-ok__title">Form 60 Not Required</h3>
|
<h3 class="f60-pan-ok__title">Form 97 Not Required</h3>
|
||||||
<p class="f60-pan-ok__desc">
|
<p class="f60-pan-ok__desc">
|
||||||
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
||||||
@if (panVerified) {
|
@if (panVerified) {
|
||||||
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 60 is only required when PAN is not available.</p>
|
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 97 is only required when PAN is not available.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div class="f60-warning">
|
<div class="f60-warning">
|
||||||
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
||||||
<div class="f60-warning__text">
|
<div class="f60-warning__text">
|
||||||
<strong>PAN not provided</strong> — Form 60 declaration is mandatory as per Income Tax Rule 114B.
|
<strong>PAN not provided</strong> — Form 97 declaration is mandatory as per Income Tax Rule 114B.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<div class="f60-card">
|
<div class="f60-card">
|
||||||
<div class="f60-card__header">
|
<div class="f60-card__header">
|
||||||
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
||||||
<h3>Form 60 Declaration</h3>
|
<h3>Form 97 Declaration</h3>
|
||||||
<span class="f60-card__status">Submitted</span>
|
<span class="f60-card__status">Submitted</span>
|
||||||
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
||||||
<mat-icon>edit</mat-icon> Edit
|
<mat-icon>edit</mat-icon> Edit
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<div class="f60-card">
|
<div class="f60-card">
|
||||||
<div class="f60-card__header">
|
<div class="f60-card__header">
|
||||||
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
||||||
<h3>Form 60 Declaration</h3>
|
<h3>Form 97 Declaration</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="f60-card__body">
|
<div class="f60-card__body">
|
||||||
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
||||||
}
|
}
|
||||||
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
||||||
<mat-icon>check</mat-icon> Submit Form 60
|
<mat-icon>check</mat-icon> Submit Form 97
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -224,14 +224,14 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
|||||||
this.isEditMode = false;
|
this.isEditMode = false;
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Form 60 submitted successfully.'
|
message: 'Form 97 submitted successfully.'
|
||||||
});
|
});
|
||||||
this.loadForm60Data();
|
this.loadForm60Data();
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 60.'
|
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 97.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="history-tab">
|
||||||
|
<div class="history-tab__header">
|
||||||
|
<h3>Change History</h3>
|
||||||
|
<p>Past changes to this client's details and address, pulled from the system Audit Trail.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="history-tab__table-container">
|
||||||
|
@if (isLoading) {
|
||||||
|
<mat-progress-bar mode="indeterminate" class="history-tab__loader"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isLoading && dataSource.data.length === 0) {
|
||||||
|
<div class="history-tab__empty">No recorded changes for this client yet.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isLoading && dataSource.data.length > 0) {
|
||||||
|
<table mat-table [dataSource]="dataSource" class="history-tab__table">
|
||||||
|
<ng-container matColumnDef="entityName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Entity</th>
|
||||||
|
<td mat-cell *matCellDef="let row">{{ row.entityName | translateKey: 'auditTrail.entities' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actionName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<span class="history-tab__action-badge">{{ row.actionName | translateKey: 'auditTrail.actions' }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="fieldsChanged">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Fields Changed</th>
|
||||||
|
<td mat-cell *matCellDef="let row" class="history-tab__fields">{{ fieldsChanged(row) || '—' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="maker">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Made By</th>
|
||||||
|
<td mat-cell *matCellDef="let row">{{ row.maker }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="madeOnDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Made On</th>
|
||||||
|
<td mat-cell *matCellDef="let row" class="history-tab__mono">{{ row.madeOnDate | datetimeFormat }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="processingResult">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<span
|
||||||
|
class="history-tab__status"
|
||||||
|
[class.history-tab__status--success]="row.processingResult === 'SUCCESS'"
|
||||||
|
[class.history-tab__status--failure]="row.processingResult === 'FAILURE'"
|
||||||
|
[class.history-tab__status--awaiting]="row.processingResult === 'AWAITING_APPROVAL'"
|
||||||
|
>
|
||||||
|
{{ row.processingResult }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr
|
||||||
|
mat-row
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="history-tab__row"
|
||||||
|
[routerLink]="['/system', 'audit-trails', row.id]"
|
||||||
|
></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<mat-paginator [pageSize]="10" [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.history-tab {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__table-container {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__loader {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__empty {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(0 0 0 / 55%);
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__fields {
|
||||||
|
max-width: 320px;
|
||||||
|
white-space: normal;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__mono {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__action-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1074b9;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-tab__status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failure {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--awaiting {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, ViewChild, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
|
import {
|
||||||
|
MatTableDataSource,
|
||||||
|
MatTable,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCellDef,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatCell,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatRowDef,
|
||||||
|
MatRow
|
||||||
|
} from '@angular/material/table';
|
||||||
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
|
|
||||||
|
/** rxjs Imports */
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { SystemService } from 'app/system/system.service';
|
||||||
|
import { DatetimeFormatPipe } from '../../../pipes/datetime-format.pipe';
|
||||||
|
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client "History" tab — a read-only, client-scoped view over the EXISTING system-wide
|
||||||
|
* Audit Trail data (no new backend endpoint). Reuses `SystemService.getAuditTrails()`
|
||||||
|
* exactly as `AuditTrailsComponent` does, pre-filtered to `entityName IN (CLIENT, ADDRESS)`
|
||||||
|
* + `resourceId = clientId` — two calls (the underlying `filterBy` shape only carries one
|
||||||
|
* `entityName` value at a time) merged and sorted client-side.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-clients-history-tab',
|
||||||
|
templateUrl: './history-tab.component.html',
|
||||||
|
styleUrls: ['./history-tab.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatProgressBar,
|
||||||
|
MatTable,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCellDef,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatCell,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatRowDef,
|
||||||
|
MatRow,
|
||||||
|
MatPaginator,
|
||||||
|
DatetimeFormatPipe,
|
||||||
|
TranslatePipe
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class HistoryTabComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private systemService = inject(SystemService);
|
||||||
|
|
||||||
|
/** Client ID (from the parent `:clientId` route param, same pattern as notes-tab). */
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
displayedColumns: string[] = [
|
||||||
|
'entityName',
|
||||||
|
'actionName',
|
||||||
|
'fieldsChanged',
|
||||||
|
'maker',
|
||||||
|
'madeOnDate',
|
||||||
|
'processingResult'
|
||||||
|
];
|
||||||
|
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.clientId = this.route.parent.snapshot.params['clientId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditFilterFor(entityName: string): { type: string; value: string }[] {
|
||||||
|
return [
|
||||||
|
{ type: 'entityName', value: entityName },
|
||||||
|
{ type: 'resourceId', value: this.clientId }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadHistory(): void {
|
||||||
|
this.isLoading = true;
|
||||||
|
forkJoin({
|
||||||
|
client: this.systemService.getAuditTrails(this.auditFilterFor('CLIENT'), '', '', 0, 200),
|
||||||
|
address: this.systemService.getAuditTrails(this.auditFilterFor('ADDRESS'), '', '', 0, 200)
|
||||||
|
}).subscribe({
|
||||||
|
next: ({ client, address }: { client: any; address: any }) => {
|
||||||
|
const merged = [
|
||||||
|
...(client?.pageItems || []),
|
||||||
|
...(address?.pageItems || [])
|
||||||
|
];
|
||||||
|
// Most recent first — audit trail ids are monotonically increasing.
|
||||||
|
merged.sort((a, b) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||||
|
this.dataSource = new MatTableDataSource(merged);
|
||||||
|
this.dataSource.paginator = this.paginator;
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarises which fields a row changed, parsed from `commandAsJson` — same source
|
||||||
|
* `ViewAuditComponent` parses for its command/value table, just condensed to a
|
||||||
|
* comma-separated field list for this compact history view. Boilerplate command
|
||||||
|
* params (locale/dateFormat) are excluded as noise.
|
||||||
|
*/
|
||||||
|
fieldsChanged(row: any): string {
|
||||||
|
if (!row?.commandAsJson) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.commandAsJson);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const ignore = new Set([
|
||||||
|
'locale',
|
||||||
|
'dateFormat'
|
||||||
|
]);
|
||||||
|
return Object.keys(parsed)
|
||||||
|
.filter((key) => !ignore.has(key))
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed/absent commandAsJson — leave blank rather than throw.
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
||||||
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
||||||
>
|
>
|
||||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,12 +98,10 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Account no Column -->
|
<!-- Client ID Column -->
|
||||||
<ng-container matColumnDef="accountNumber">
|
<ng-container matColumnDef="accountNumber">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||||
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- CIF Number Column -->
|
<!-- CIF Number Column -->
|
||||||
|
|||||||
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
|
|||||||
private getClients() {
|
private getClients() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.clientService
|
this.clientService
|
||||||
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
|
.searchByText(
|
||||||
|
this.filterText,
|
||||||
|
this.currentPage,
|
||||||
|
this.pageSize,
|
||||||
|
this.sortAttribute,
|
||||||
|
this.sortDirection,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
true
|
||||||
|
)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
this.allRows = data.content || [];
|
this.allRows = data.content || [];
|
||||||
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
||||||
clients.forEach((client, i) => {
|
clients.forEach((client, i) => {
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientData(client.id?.toString())
|
.getClientData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((data: any) => {
|
.subscribe((data: any) => {
|
||||||
if (data?.legalForm) {
|
if (data?.legalForm) {
|
||||||
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientKyc(client.id?.toString())
|
.getClientKyc(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((kyc: any) => {
|
.subscribe((kyc: any) => {
|
||||||
if (kyc) {
|
if (kyc) {
|
||||||
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientAddressData(client.id?.toString())
|
.getClientAddressData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((addr: any) => {
|
.subscribe((addr: any) => {
|
||||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpBackend, HttpHeaders, HttpContext } from '@angular/common/http';
|
||||||
|
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable, of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
@@ -16,6 +17,7 @@ import { switchMap } from 'rxjs/operators';
|
|||||||
import { map, catchError } from 'rxjs/operators';
|
import { map, catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
import { environment } from 'environments/environment';
|
import { environment } from 'environments/environment';
|
||||||
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clients service.
|
* Clients service.
|
||||||
@@ -65,8 +67,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientData(clientId: string) {
|
getClientData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/clients/${clientId}`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createClient(client: any) {
|
createClient(client: any) {
|
||||||
@@ -81,6 +86,43 @@ export class ClientsService {
|
|||||||
return this.http.delete(`/clients/${clientId}`);
|
return this.http.delete(`/clients/${clientId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker-checker for Client edits — stages the change in m_client_pending_edit instead of
|
||||||
|
* applying it immediately; a different user must call approveClientEdit() before it takes
|
||||||
|
* effect. See ClientEditApiResource on the backend.
|
||||||
|
*/
|
||||||
|
submitClientEdit(clientId: string, client: any) {
|
||||||
|
return this.http.post(`/clients/${clientId}/edit`, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingClientEdit(clientId: string) {
|
||||||
|
return this.http.get(`/clients/${clientId}/edit/pending`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPendingClientEdits(
|
||||||
|
status?: string,
|
||||||
|
clientName?: string,
|
||||||
|
fromDate?: string,
|
||||||
|
toDate?: string,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (clientName) params = params.set('clientName', clientName);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get(`/clients/edit/pending`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveClientEdit(clientId: string) {
|
||||||
|
return this.http.post(`/clients/${clientId}/edit/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectClientEdit(clientId: string, rejectReason?: string) {
|
||||||
|
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||||
|
}
|
||||||
|
|
||||||
getClientDataAndTemplate(clientId: string) {
|
getClientDataAndTemplate(clientId: string) {
|
||||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||||
@@ -115,8 +157,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/accounts`);
|
return this.http.get(`/clients/${clientId}/accounts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientAddressData(clientId: string) {
|
getClientAddressData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/client/${clientId}/addresses`);
|
return this.http.get(
|
||||||
|
`/client/${clientId}/addresses`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientChargesData(clientId: string) {
|
getClientChargesData(clientId: string) {
|
||||||
@@ -230,6 +275,63 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/documents/${documentId}/attachment`, { responseType: 'blob' });
|
return this.http.get(`/clients/${clientId}/documents/${documentId}/attachment`, { responseType: 'blob' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a webcam-captured signature (photograph of a paper signature).
|
||||||
|
* Mirrors `uploadClientSignatureImage()`'s multipart shape (name=clientSignature)
|
||||||
|
* exactly, but the source is a captured `data:` URL rather than a picked `File`.
|
||||||
|
*/
|
||||||
|
uploadCapturedClientSignature(clientId: string, imageDataUrl: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', dataUrlToFile(imageDataUrl, 'clientSignature.jpg'));
|
||||||
|
formData.append('name', 'clientSignature');
|
||||||
|
formData.append('description', 'Client signature (captured via webcam)');
|
||||||
|
return this.http.post(`/clients/${clientId}/documents`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an OTP against a client's OLD or NEW mobile/email destination, ahead of a
|
||||||
|
* sensitive contact-detail change. Dual-OTP verification (old + new) is required before
|
||||||
|
* the mobileNo/emailAddress change is accepted by the backend — see `verifyClientContactOtp()`.
|
||||||
|
* @param clientId Client ID
|
||||||
|
* @param channel 'MOBILE' | 'EMAIL'
|
||||||
|
* @param destination Raw mobile number or email address to send the OTP to
|
||||||
|
*/
|
||||||
|
sendClientContactOtp(clientId: string, channel: 'MOBILE' | 'EMAIL', destination: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/${clientId}/contact-verification/otp`, { channel, destination });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an OTP previously generated via `sendClientContactOtp()`.
|
||||||
|
* @param clientId Client ID
|
||||||
|
* @param otpId ID returned by the generate call
|
||||||
|
* @param otp 6-digit code entered by staff on the customer's behalf
|
||||||
|
*/
|
||||||
|
verifyClientContactOtp(clientId: string, otpId: number, otp: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/${clientId}/contact-verification/otp/verify`, { otpId, otp });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an OTP against a NOT-YET-CREATED client's mobile/email, ahead of the initial
|
||||||
|
* `POST /clients` call. There is no clientId yet, so this hits the clientId-less sibling
|
||||||
|
* endpoint (`ClientCreationContactVerificationApiResource`) rather than
|
||||||
|
* `sendClientContactOtp()`'s `/clients/{clientId}/...` shape. The returned otpId is sent
|
||||||
|
* back as `mobileNewOtpId`/`emailNewOtpId` on `createClient()`.
|
||||||
|
* @param channel 'MOBILE' | 'EMAIL'
|
||||||
|
* @param destination Raw mobile number or email address to send the OTP to
|
||||||
|
*/
|
||||||
|
sendClientCreationContactOtp(channel: 'MOBILE' | 'EMAIL', destination: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/contact-verification/otp`, { channel, destination });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an OTP previously generated via `sendClientCreationContactOtp()`.
|
||||||
|
* @param otpId ID returned by the generate call
|
||||||
|
* @param otp 6-digit code entered by staff on the customer's behalf
|
||||||
|
*/
|
||||||
|
verifyClientCreationContactOtp(otpId: number, otp: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/contact-verification/otp/verify`, { otpId, otp });
|
||||||
|
}
|
||||||
|
|
||||||
getClientFamilyMembers(clientId: string) {
|
getClientFamilyMembers(clientId: string) {
|
||||||
return this.http.get(`/clients/${clientId}/familymembers`);
|
return this.http.get(`/clients/${clientId}/familymembers`);
|
||||||
}
|
}
|
||||||
@@ -603,7 +705,8 @@ export class ClientsService {
|
|||||||
sortAttribute: string = '',
|
sortAttribute: string = '',
|
||||||
sortDirection: string = '',
|
sortDirection: string = '',
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
legalFormType: string = ''
|
legalFormType: string = '',
|
||||||
|
silent: boolean = false
|
||||||
) {
|
) {
|
||||||
const innerRequest: any = { text };
|
const innerRequest: any = { text };
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
@@ -632,7 +735,11 @@ export class ClientsService {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.http.post(`/v2/clients/search`, request);
|
return this.http.post(
|
||||||
|
`/v2/clients/search`,
|
||||||
|
request,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -669,8 +776,11 @@ export class ClientsService {
|
|||||||
* Gets KYC details for a client.
|
* Gets KYC details for a client.
|
||||||
* @param clientId Client ID
|
* @param clientId Client ID
|
||||||
*/
|
*/
|
||||||
getClientKyc(clientId: string): Observable<any> {
|
getClientKyc(clientId: string, silent = false): Observable<any> {
|
||||||
return this.http.get(`/clients/${clientId}/kyc`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}/kyc`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -681,6 +791,10 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/kyc/template`);
|
return this.http.get(`/clients/${clientId}/kyc/template`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getClientRiskRules(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/kyc/risk-rules');
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch code values by code ID */
|
/** Fetch code values by code ID */
|
||||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||||
@@ -836,4 +950,12 @@ export class ClientsService {
|
|||||||
submitClientForm60(clientId: string, data: any): Observable<any> {
|
submitClientForm60(clientId: string, data: any): Observable<any> {
|
||||||
return this.http.post(`/clients/${clientId}/form60`, data);
|
return this.http.post(`/clients/${clientId}/form60`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWelcomeKits(clientId: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/clients/${clientId}/welcomekit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||||
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,12 @@
|
|||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="createdClientId"
|
[clientId]="createdClientId"
|
||||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,6 +148,8 @@
|
|||||||
}
|
}
|
||||||
<mifosx-client-india-address-step
|
<mifosx-client-india-address-step
|
||||||
#clientIndiaAddress
|
#clientIndiaAddress
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ export class CreateClientComponent {
|
|||||||
this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => {
|
this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => {
|
||||||
this.clientTemplate = data.clientTemplate;
|
this.clientTemplate = data.clientTemplate;
|
||||||
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
||||||
|
// This app's RouteReusableStrategy keeps this SAME component instance alive across
|
||||||
|
// repeat visits to /clients/create, so ngOnInit/constructor won't re-run for a second
|
||||||
|
// "create new client" session — reset transient state here instead, since `route.data`
|
||||||
|
// re-emits on every navigation regardless of whether the component was reused. Must run
|
||||||
|
// BEFORE setDatatables(), which reads legalFormType to filter clientTemplate.datatables.
|
||||||
|
this.resetForNewClient();
|
||||||
this.setDatatables();
|
this.setDatatables();
|
||||||
});
|
});
|
||||||
// Read pre-fill data set by "Create as Client" from a manual family member
|
// Read pre-fill data set by "Create as Client" from a manual family member
|
||||||
@@ -121,6 +127,22 @@ export class CreateClientComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resets session state that could otherwise leak between clients when the route is reused. */
|
||||||
|
private resetForNewClient(): void {
|
||||||
|
this.legalFormType = 1;
|
||||||
|
this.selectedTabIndex = 0;
|
||||||
|
this.createdClientId = '';
|
||||||
|
this.savingClient = false;
|
||||||
|
this.saveProgress = 0;
|
||||||
|
this.tabEditMode = {};
|
||||||
|
this.highestTabReached = 0;
|
||||||
|
this.validationError = '';
|
||||||
|
this.prefillData = null;
|
||||||
|
this.clientGeneralStep?.resetForNewClient();
|
||||||
|
this.clientKycStep?.resetForNewClient();
|
||||||
|
this.clientIndiaAddressStep?.resetForNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if a tab is in read-only mode (completed tab navigated back to) */
|
/** Check if a tab is in read-only mode (completed tab navigated back to) */
|
||||||
isTabReadOnly(tabIndex: number): boolean {
|
isTabReadOnly(tabIndex: number): boolean {
|
||||||
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
||||||
@@ -192,7 +214,10 @@ export class CreateClientComponent {
|
|||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||||
return this.clientGeneralForm.valid;
|
if (!this.clientGeneralForm.valid) return false;
|
||||||
|
// Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new
|
||||||
|
// client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent.
|
||||||
|
return this.clientGeneralStep.contactVerified;
|
||||||
}
|
}
|
||||||
if (idx === 1) {
|
if (idx === 1) {
|
||||||
// KYC tab — block when the kyc form has unfilled required fields
|
// KYC tab — block when the kyc form has unfilled required fields
|
||||||
@@ -215,8 +240,8 @@ export class CreateClientComponent {
|
|||||||
return this.isAddressStepValid();
|
return this.isAddressStepValid();
|
||||||
}
|
}
|
||||||
if (idx === 3) {
|
if (idx === 3) {
|
||||||
// Family tab — relations are optional, always allow.
|
// Family tab — Mother is required, and either Father or a Caretaker.
|
||||||
return true;
|
return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -241,6 +266,20 @@ export class CreateClientComponent {
|
|||||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
return 'Client failed sanctions screening — cannot proceed.';
|
return 'Client failed sanctions screening — cannot proceed.';
|
||||||
}
|
}
|
||||||
|
if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) {
|
||||||
|
return 'Verify the OTP sent to both the mobile number and email address before proceeding.';
|
||||||
|
}
|
||||||
|
if (idx === 1) {
|
||||||
|
const labels = this.clientKycStep?.invalidFieldLabels || [];
|
||||||
|
if (labels.length) {
|
||||||
|
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idx === 3 && this.legalFormType === 1 && this.clientFamilyMembersStep) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||||
|
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
return 'Please fill all mandatory fields on this tab to continue.';
|
return 'Please fill all mandatory fields on this tab to continue.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +348,28 @@ export class CreateClientComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contact-OTP gate — defensive re-check. The Save & Next button is already disabled via
|
||||||
|
// `currentStepValid` while this is incomplete, but don't rely on that alone (mirrors the
|
||||||
|
// same defensive re-check in EditClientComponent.confirmAndSaveTab()).
|
||||||
|
if (!this.clientGeneralStep?.contactVerified) {
|
||||||
|
this.snackBar.open('Verify the OTP sent to both the mobile number and email address before saving.', 'Close', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PWD gate — disability type + certificate are mandatory when PWD is checked
|
||||||
|
// (Senior Citizen proof upload is optional, not gated here)
|
||||||
|
if (!this.clientGeneralStep?.seniorPwdDocsComplete) {
|
||||||
|
this.clientGeneralStep?.markSeniorPwdDocsTouched();
|
||||||
|
this.snackBar.open(
|
||||||
|
'Select the disability type and upload the Disability Certificate before continuing.',
|
||||||
|
'Close',
|
||||||
|
{ duration: 5000 }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sanctions verification gate — Verify Client must be clicked before save
|
// Sanctions verification gate — Verify Client must be clicked before save
|
||||||
if (!this.clientGeneralStep?.sanctionsVerified) {
|
if (!this.clientGeneralStep?.sanctionsVerified) {
|
||||||
this.dialog
|
this.dialog
|
||||||
@@ -426,6 +487,12 @@ export class CreateClientComponent {
|
|||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const clientData = { ...this.client, dateFormat, locale };
|
const clientData = { ...this.client, dateFormat, locale };
|
||||||
|
|
||||||
|
// Mandatory contact-OTP references — required by POST /clients whenever mobileNo/
|
||||||
|
// emailAddress are present (they always are, both are Validators.required on this form).
|
||||||
|
// Gated by `contactVerified` above, so these are guaranteed non-null at this point.
|
||||||
|
clientData.mobileNewOtpId = this.clientGeneralStep?.mobileNewOtpId;
|
||||||
|
clientData.emailNewOtpId = this.clientGeneralStep?.emailNewOtpId;
|
||||||
|
|
||||||
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
||||||
const datatables: any[] = [];
|
const datatables: any[] = [];
|
||||||
this.clientDatatables?.forEach((clientDatatable: ClientDatatableStepComponent) => {
|
this.clientDatatables?.forEach((clientDatatable: ClientDatatableStepComponent) => {
|
||||||
@@ -441,6 +508,29 @@ export class CreateClientComponent {
|
|||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
this.saveProgress = 100;
|
this.saveProgress = 100;
|
||||||
this.createdClientId = response.resourceId;
|
this.createdClientId = response.resourceId;
|
||||||
|
|
||||||
|
// Senior Citizen / PWD supporting documents — uploaded after creation, same pattern as
|
||||||
|
// the KYC step's photo/signature uploads (the generic /clients/{id}/documents endpoint
|
||||||
|
// needs a real clientId, which only exists once createClient() has returned).
|
||||||
|
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.createdClientId, 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.createdClientId, pwdCertificateForm)
|
||||||
|
.subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
// Brief delay to show 100% before transitioning
|
// Brief delay to show 100% before transitioning
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
@@ -486,6 +576,21 @@ export class CreateClientComponent {
|
|||||||
this.selectedTabIndex = 3;
|
this.selectedTabIndex = 3;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Validate: Mother required, and either Father or a Caretaker (Individual clients)
|
||||||
|
if (
|
||||||
|
this.legalFormType === 1 &&
|
||||||
|
this.clientFamilyMembersStep &&
|
||||||
|
!this.clientFamilyMembersStep.familyRelationshipRulesMet
|
||||||
|
) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) {
|
||||||
|
this.validationError = 'Mother is required as a family member.';
|
||||||
|
} else {
|
||||||
|
this.validationError = 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
|
this.selectedTabIndex = 3;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.validationError = '';
|
this.validationError = '';
|
||||||
|
|
||||||
if (this.createdClientId) {
|
if (this.createdClientId) {
|
||||||
@@ -493,12 +598,19 @@ export class CreateClientComponent {
|
|||||||
this.finalizeClient(this.createdClientId);
|
this.finalizeClient(this.createdClientId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: create client now (shouldn't happen in normal flow)
|
// Fallback: create client now (shouldn't happen in normal flow)
|
||||||
|
if (!this.clientGeneralStep?.contactVerified) {
|
||||||
|
this.validationError = 'Verify the OTP sent to both the mobile number and email address before submitting.';
|
||||||
|
this.selectedTabIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const clientData = {
|
const clientData = {
|
||||||
...this.client,
|
...this.client,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale,
|
||||||
|
mobileNewOtpId: this.clientGeneralStep?.mobileNewOtpId,
|
||||||
|
emailNewOtpId: this.clientGeneralStep?.emailNewOtpId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cotp-block" [class.cotp-block--verified]="verified">
|
||||||
|
<div class="cotp-block__header">
|
||||||
|
<span class="cotp-block__label">{{ label }}</span>
|
||||||
|
<span class="cotp-block__target">{{ maskedDestination }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (verified) {
|
||||||
|
<div class="cotp-block__verified">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>Verified</span>
|
||||||
|
</div>
|
||||||
|
} @else if (!otpSent) {
|
||||||
|
<button type="button" class="cotp-btn cotp-btn--primary" (click)="sendOtp()" [disabled]="sending || !destination">
|
||||||
|
@if (sending) {
|
||||||
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
<span>Sending…</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Send OTP</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="cotp-otp">
|
||||||
|
@for (i of digitIndexes; track i) {
|
||||||
|
<input
|
||||||
|
#digitBox
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="1"
|
||||||
|
class="cotp-otp__box"
|
||||||
|
[value]="digitControls[i].value"
|
||||||
|
[attr.autocomplete]="i === 0 ? 'one-time-code' : 'off'"
|
||||||
|
(input)="onDigitInput(i, $any($event.target).value)"
|
||||||
|
(keydown)="onDigitKeydown(i, $event)"
|
||||||
|
(paste)="onDigitPaste($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cotp-block__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cotp-btn cotp-btn--primary"
|
||||||
|
(click)="verifyOtp()"
|
||||||
|
[disabled]="!codeValid || verifying"
|
||||||
|
>
|
||||||
|
@if (verifying) {
|
||||||
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
<span>Verifying…</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>Verify</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="cotp-btn cotp-btn--ghost" (click)="resendOtp()" [disabled]="resendCountdown > 0">
|
||||||
|
<fa-icon icon="redo"></fa-icon>
|
||||||
|
<span>{{ resendCountdown > 0 ? 'Resend (' + resendCountdown + 's)' : 'Resend OTP' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage) {
|
||||||
|
<div class="cotp-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Card with 6 individually-bordered digit boxes (not the single-input/background-gradient
|
||||||
|
* trick used elsewhere in this app) — each box is its own <input>, so focus/border state is
|
||||||
|
* per-digit rather than shared across the whole code. */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border: 1px solid #e7ecf3;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 10%);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&--verified {
|
||||||
|
border-color: #86efac;
|
||||||
|
background: #f7fdf9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block__label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block__target {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1074b9;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block__verified {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-block__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-otp {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-otp__box {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 46px;
|
||||||
|
height: 52px;
|
||||||
|
border: 1.5px solid #dde3ec;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
caret-color: #1074b9;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #b7c2d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #1074b9;
|
||||||
|
box-shadow: 0 0 0 3px rgb(16 116 185 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1 1 0;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: #1074b9;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 16px -4px rgb(16 116 185 / 45%);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0d5f96;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
background: #fff;
|
||||||
|
color: #1074b9;
|
||||||
|
border-color: #cfe0ee;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: #1074b9;
|
||||||
|
background: #f0f7fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-progress-spinner ::ng-deep circle {
|
||||||
|
stroke: currentcolor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cotp-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 420px) {
|
||||||
|
.cotp-otp__box {
|
||||||
|
width: 38px;
|
||||||
|
height: 46px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
QueryList,
|
||||||
|
SimpleChanges,
|
||||||
|
ViewChildren,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { UntypedFormControl } from '@angular/forms';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ClientsService } from '../../clients.service';
|
||||||
|
|
||||||
|
/** Emitted every time this block's verification result changes — including a reset back
|
||||||
|
* to `{ otpId: null, verified: false }` when the destination it was verifying no longer
|
||||||
|
* matches (e.g. staff kept editing the mobile number after verifying an earlier value). */
|
||||||
|
export interface ContactOtpState {
|
||||||
|
otpId: number | null;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small presentational OTP-entry block reused up to four times inside the Edit Client
|
||||||
|
* "Verify Contact Change" panel (old mobile / new mobile / old email / new email — see
|
||||||
|
* `EditClientComponent`), and once each for mobile/email in the Create Client flow (see
|
||||||
|
* `ClientGeneralStepComponent`). Renders as 6 individually-bordered digit boxes (one real
|
||||||
|
* `<input>` each, auto-advancing focus) rather than the single-input/background-gradient trick
|
||||||
|
* used elsewhere in this app — each box needs its own focus/border state, which a shared input
|
||||||
|
* can't express.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-client-contact-otp-block',
|
||||||
|
templateUrl: './contact-otp-block.component.html',
|
||||||
|
styleUrls: ['./contact-otp-block.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
MatProgressSpinner
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ContactOtpBlockComponent implements OnChanges, OnDestroy {
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
/** The 6 individual digit-box <input> elements, in order — used to move focus between them. */
|
||||||
|
@ViewChildren('digitBox') digitBoxes: QueryList<ElementRef<HTMLInputElement>>;
|
||||||
|
|
||||||
|
/** One independent control per box — NOT a single 6-char string sliced up. Editing box 0
|
||||||
|
* can never disturb box 3's value this way. (An earlier version kept one shared string and
|
||||||
|
* rebuilt it via `chars.join('')` on every keystroke — `['', '2', '3'].join('') === '23'`
|
||||||
|
* silently drops empty slots instead of preserving them as gaps, so deleting an early digit
|
||||||
|
* collapsed/shifted every digit after it. Per-box controls sidestep that class of bug
|
||||||
|
* entirely — there's no string to rebuild until the moment we actually submit.) */
|
||||||
|
readonly digitControls: UntypedFormControl[] = Array.from({ length: 6 }, () => new UntypedFormControl(''));
|
||||||
|
|
||||||
|
/** Fixed-length array so the template can `*ngFor` over exactly 6 boxes. */
|
||||||
|
readonly digitIndexes = [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Client whose mobileNo/emailAddress is being changed. Omit this input entirely for the
|
||||||
|
* CLIENT CREATION flow — there is no client id yet, so the block falls back to the
|
||||||
|
* clientId-less creation-time OTP endpoints (`ClientsService.sendClientCreationContactOtp()` /
|
||||||
|
* `verifyClientCreationContactOtp()`) instead of the edit-flow `/clients/{clientId}/...` ones. */
|
||||||
|
@Input() clientId?: string;
|
||||||
|
/** Which LincPay OTP channel to use. */
|
||||||
|
@Input() channel: 'MOBILE' | 'EMAIL';
|
||||||
|
/** Raw destination (old or new mobile number / email address) this block verifies. */
|
||||||
|
@Input() destination = '';
|
||||||
|
/** Display label, e.g. "Old Mobile Number" / "New Email Address". */
|
||||||
|
@Input() label = '';
|
||||||
|
|
||||||
|
/** Fires whenever `{ otpId, verified }` changes — including resets. */
|
||||||
|
@Output() stateChange = new EventEmitter<ContactOtpState>();
|
||||||
|
|
||||||
|
otpId: number | null = null;
|
||||||
|
otpSent = false;
|
||||||
|
verified = false;
|
||||||
|
sending = false;
|
||||||
|
verifying = false;
|
||||||
|
errorMessage = '';
|
||||||
|
resendCountdown = 0;
|
||||||
|
private resendTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** The full 6-digit code, assembled fresh from the 6 independent box controls only when
|
||||||
|
* actually needed (display never needs this — each box renders its own control's value). */
|
||||||
|
get code(): string {
|
||||||
|
return this.digitControls.map((c) => c.value || '').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
get codeValid(): boolean {
|
||||||
|
return /^\d{6}$/.test(this.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
// If the value being verified changes after the fact (staff kept typing), any
|
||||||
|
// previously-sent/verified OTP no longer applies to the current destination.
|
||||||
|
if (changes['destination'] && !changes['destination'].firstChange && this.otpSent) {
|
||||||
|
this.resetState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masks the destination so only a few characters are visible, mirroring the masking
|
||||||
|
* convention already used on the login 2FA screen (`maskTarget()`). */
|
||||||
|
get maskedDestination(): string {
|
||||||
|
const value = this.destination || '';
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (this.channel === 'MOBILE') {
|
||||||
|
const digits = value.replace(/\D/g, '');
|
||||||
|
if (digits.length <= 4) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'X'.repeat(digits.length - 4) + digits.slice(-4);
|
||||||
|
}
|
||||||
|
const at = value.indexOf('@');
|
||||||
|
if (at <= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const local = value.substring(0, at);
|
||||||
|
const domain = value.substring(at);
|
||||||
|
const visible = local.substring(0, Math.min(2, local.length));
|
||||||
|
const hidden = 'X'.repeat(Math.max(0, local.length - visible.length));
|
||||||
|
return visible + hidden + domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendOtp(): void {
|
||||||
|
if (!this.destination || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sending = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
const request$ = this.clientId
|
||||||
|
? this.clientsService.sendClientContactOtp(this.clientId, this.channel, this.destination)
|
||||||
|
: this.clientsService.sendClientCreationContactOtp(this.channel, this.destination);
|
||||||
|
request$.subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.otpId = res?.otpId ?? res?.resourceId ?? null;
|
||||||
|
this.otpSent = true;
|
||||||
|
this.clearAllDigits();
|
||||||
|
this.startResendCountdown();
|
||||||
|
// The 6 digit boxes only render once otpSent flips true — defer the focus call to the
|
||||||
|
// next tick so they exist in the DOM (and in `digitBoxes`) by the time it runs.
|
||||||
|
setTimeout(() => this.focusBox(0));
|
||||||
|
if (this.otpId == null) {
|
||||||
|
this.errorMessage = 'OTP sent, but no reference id was returned. Please contact support.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send OTP.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typing a digit in box `index`: this box's own control gets exactly that digit — every
|
||||||
|
* other box's control is untouched, so there's nothing to shift or collapse. Moves focus to
|
||||||
|
* the next box unless this was the last one. */
|
||||||
|
onDigitInput(index: number, rawValue: string): void {
|
||||||
|
const digit = (rawValue || '').replace(/\D/g, '').slice(-1);
|
||||||
|
this.digitControls[index].setValue(digit);
|
||||||
|
|
||||||
|
if (digit && index < 5) {
|
||||||
|
this.focusBox(index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backspace in an empty box clears the PREVIOUS box and moves focus there, matching the
|
||||||
|
* usual "segmented OTP input" keyboard feel — again, only ever touching one control at a
|
||||||
|
* time. Arrow keys move focus without altering any box's value. */
|
||||||
|
onDigitKeydown(index: number, event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
if (!this.digitControls[index].value && index > 0) {
|
||||||
|
this.digitControls[index - 1].setValue('');
|
||||||
|
this.focusBox(index - 1);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowLeft' && index > 0) {
|
||||||
|
this.focusBox(index - 1);
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'ArrowRight' && index < 5) {
|
||||||
|
this.focusBox(index + 1);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pasting a full code into any box fills all 6 boxes at once (one control per digit) and
|
||||||
|
* focuses the last filled one. */
|
||||||
|
onDigitPaste(event: ClipboardEvent): void {
|
||||||
|
const pasted = event.clipboardData?.getData('text') || '';
|
||||||
|
const digits = pasted.replace(/\D/g, '').slice(0, 6);
|
||||||
|
if (!digits) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
digits.split('').forEach((d, i) => this.digitControls[i].setValue(d));
|
||||||
|
for (let i = digits.length; i < 6; i++) {
|
||||||
|
this.digitControls[i].setValue('');
|
||||||
|
}
|
||||||
|
this.focusBox(Math.min(digits.length, 6) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAllDigits(): void {
|
||||||
|
this.digitControls.forEach((c) => c.setValue(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusBox(index: number): void {
|
||||||
|
const box = this.digitBoxes?.toArray()[index];
|
||||||
|
box?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
resendOtp(): void {
|
||||||
|
if (this.resendCountdown > 0 || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendOtp();
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyOtp(): void {
|
||||||
|
if (this.otpId == null || !this.codeValid || this.verifying) {
|
||||||
|
this.digitControls.forEach((c) => c.markAsTouched());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.verifying = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
const request$ = this.clientId
|
||||||
|
? this.clientsService.verifyClientContactOtp(this.clientId, this.otpId, this.code)
|
||||||
|
: this.clientsService.verifyClientCreationContactOtp(this.otpId, this.code);
|
||||||
|
request$.subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.verifying = false;
|
||||||
|
// Defensive shape-tolerance: treat an explicit `verified: false` as failure,
|
||||||
|
// anything else (2xx with `verified: true`, or no `verified` field at all) as success.
|
||||||
|
const ok = res?.verified !== false;
|
||||||
|
if (ok) {
|
||||||
|
this.verified = true;
|
||||||
|
this.stateChange.emit({ otpId: this.otpId, verified: true });
|
||||||
|
} else {
|
||||||
|
this.errorMessage = res?.message || 'Incorrect OTP — please try again.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.verifying = false;
|
||||||
|
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'OTP verification failed.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetState(): void {
|
||||||
|
this.otpId = null;
|
||||||
|
this.otpSent = false;
|
||||||
|
this.verified = false;
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.clearAllDigits();
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
this.resendTimer = null;
|
||||||
|
}
|
||||||
|
this.resendCountdown = 0;
|
||||||
|
this.stateChange.emit({ otpId: null, verified: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private startResendCountdown(): void {
|
||||||
|
this.resendCountdown = 30;
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
}
|
||||||
|
this.resendTimer = setInterval(() => {
|
||||||
|
this.resendCountdown--;
|
||||||
|
if (this.resendCountdown <= 0 && this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
this.resendTimer = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,22 @@
|
|||||||
{{ 'labels.inputs.GENERAL' | translate }}
|
{{ 'labels.inputs.GENERAL' | translate }}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||||
|
@if (hasPendingGeneralEdit) {
|
||||||
|
<div class="pending-edit-banner">
|
||||||
|
<mat-icon>hourglass_top</mat-icon>
|
||||||
|
<span
|
||||||
|
>An edit is awaiting approval from another user. You can't submit another edit until it's resolved.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="tab-mode-bar">
|
<div class="tab-mode-bar">
|
||||||
@if (isTabReadOnly(0)) {
|
@if (isTabReadOnly(0)) {
|
||||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
class="tab-mode-btn tab-mode-btn--edit"
|
||||||
|
[disabled]="hasPendingGeneralEdit"
|
||||||
|
(click)="enableTabEdit(0)"
|
||||||
|
>
|
||||||
<mat-icon>edit</mat-icon> Edit
|
<mat-icon>edit</mat-icon> Edit
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -42,6 +55,79 @@
|
|||||||
[readOnly]="isTabReadOnly(0)"
|
[readOnly]="isTabReadOnly(0)"
|
||||||
(legalFormChangeEvent)="legalFormChange($event)"
|
(legalFormChangeEvent)="legalFormChange($event)"
|
||||||
></mifosx-client-general-step>
|
></mifosx-client-general-step>
|
||||||
|
|
||||||
|
<!-- Dual-OTP contact-change verification panel — appears only once the mobile
|
||||||
|
number and/or email address differ from the loaded snapshot. Save stays
|
||||||
|
gated (see currentStepValid) until every required block below is verified. -->
|
||||||
|
@if (!isTabReadOnly(0) && (contactChanges.mobileChanged || contactChanges.emailChanged)) {
|
||||||
|
<div class="contact-verify-panel">
|
||||||
|
<div class="contact-verify-panel__header">
|
||||||
|
<mat-icon>verified_user</mat-icon>
|
||||||
|
<div class="contact-verify-panel__header-text">
|
||||||
|
<strong>Verify Contact Change</strong>
|
||||||
|
<span
|
||||||
|
>An OTP must be verified on both the old and new
|
||||||
|
{{
|
||||||
|
contactChanges.mobileChanged && contactChanges.emailChanged
|
||||||
|
? 'mobile number and email address'
|
||||||
|
: contactChanges.mobileChanged
|
||||||
|
? 'mobile number'
|
||||||
|
: 'email address'
|
||||||
|
}}
|
||||||
|
before this change can be saved.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (contactChanges.mobileChanged) {
|
||||||
|
<div class="contact-verify-panel__field-group">
|
||||||
|
<div class="contact-verify-panel__field-title">
|
||||||
|
<mat-icon>phone_iphone</mat-icon>
|
||||||
|
<span>Mobile Number</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-verify-panel__grid">
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
[clientId]="clientId"
|
||||||
|
channel="MOBILE"
|
||||||
|
[destination]="contactChanges.oldMobile"
|
||||||
|
label="Old Mobile Number"
|
||||||
|
(stateChange)="onOldMobileOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
[clientId]="clientId"
|
||||||
|
channel="MOBILE"
|
||||||
|
[destination]="contactChanges.newMobile"
|
||||||
|
label="New Mobile Number"
|
||||||
|
(stateChange)="onNewMobileOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (contactChanges.emailChanged) {
|
||||||
|
<div class="contact-verify-panel__field-group">
|
||||||
|
<div class="contact-verify-panel__field-title">
|
||||||
|
<mat-icon>email</mat-icon>
|
||||||
|
<span>Email Address</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-verify-panel__grid">
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
[clientId]="clientId"
|
||||||
|
channel="EMAIL"
|
||||||
|
[destination]="contactChanges.oldEmail"
|
||||||
|
label="Old Email Address"
|
||||||
|
(stateChange)="onOldEmailOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
<mifosx-client-contact-otp-block
|
||||||
|
[clientId]="clientId"
|
||||||
|
channel="EMAIL"
|
||||||
|
[destination]="contactChanges.newEmail"
|
||||||
|
label="New Email Address"
|
||||||
|
(stateChange)="onNewEmailOtpState($event)"
|
||||||
|
></mifosx-client-contact-otp-block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|
||||||
@@ -76,8 +162,10 @@
|
|||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +221,8 @@
|
|||||||
<mifosx-client-india-address-step
|
<mifosx-client-india-address-step
|
||||||
#clientIndiaAddress
|
#clientIndiaAddress
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,6 +150,93 @@
|
|||||||
border: 1px solid #fca5a5 !important;
|
border: 1px solid #fca5a5 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Dual-OTP "Verify Contact Change" panel ─── */
|
||||||
|
.contact-verify-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: #1074b9;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1e3a5f;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field-group {
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px dashed #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e3a5f;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 17px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #1074b9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Pending Edit Approval Banner ─── */
|
||||||
|
.pending-edit-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Re-KYC Alert Banner ─── */
|
/* ─── Re-KYC Alert Banner ─── */
|
||||||
.rekyc-alert {
|
.rekyc-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ import { ClientDatatableStepComponent } from '../client-stepper/client-datatable
|
|||||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||||
|
import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/contact-otp-block.component';
|
||||||
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -36,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||||
@@ -59,7 +62,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
ClientKycStepComponent,
|
ClientKycStepComponent,
|
||||||
ClientPreviewStepComponent,
|
ClientPreviewStepComponent,
|
||||||
ClientFamilyMembersStepComponent
|
ClientFamilyMembersStepComponent,
|
||||||
|
ContactOtpBlockComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class EditClientComponent {
|
export class EditClientComponent {
|
||||||
@@ -68,6 +72,7 @@ export class EditClientComponent {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
|
||||||
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
|
||||||
@@ -88,10 +93,33 @@ export class EditClientComponent {
|
|||||||
/** Flag: DOB was changed — requires re-KYC (PAN re-verification) */
|
/** Flag: DOB was changed — requires re-KYC (PAN re-verification) */
|
||||||
dobChanged = false;
|
dobChanged = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-OTP contact-change verification state (mobileNo / emailAddress). Mirrors the
|
||||||
|
* name/DOB "detect change against snapshot, gate the save" pattern already established
|
||||||
|
* in this component (see `detectSensitiveFieldChanges()` / `flagReKycRequired()`) —
|
||||||
|
* extended here so a changed mobile/email must be OTP-verified on BOTH the old and new
|
||||||
|
* destination before Save is allowed. Payload param names sent to the backend PUT are
|
||||||
|
* `mobileOldOtpId` / `mobileNewOtpId` / `emailOldOtpId` / `emailNewOtpId` (added onto
|
||||||
|
* `clientData` in `saveCurrentTab()`'s General-tab branch) — kept per-field rather than
|
||||||
|
* one generic old/new pair so mobile AND email can both be changed (and independently
|
||||||
|
* OTP-verified) in a single save.
|
||||||
|
*/
|
||||||
|
oldMobileOtpId: number | null = null;
|
||||||
|
newMobileOtpId: number | null = null;
|
||||||
|
oldMobileVerified = false;
|
||||||
|
newMobileVerified = false;
|
||||||
|
oldEmailOtpId: number | null = null;
|
||||||
|
newEmailOtpId: number | null = null;
|
||||||
|
oldEmailVerified = false;
|
||||||
|
newEmailVerified = false;
|
||||||
|
|
||||||
/** Client data and template (merged object from resolver) */
|
/** Client data and template (merged object from resolver) */
|
||||||
clientDataAndTemplate: any;
|
clientDataAndTemplate: any;
|
||||||
/** Client ID */
|
/** Client ID */
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
|
||||||
|
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
|
||||||
|
hasPendingGeneralEdit = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||||
@@ -100,6 +128,15 @@ export class EditClientComponent {
|
|||||||
if (this.clientDataAndTemplate.legalForm) {
|
if (this.clientDataAndTemplate.legalForm) {
|
||||||
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
||||||
}
|
}
|
||||||
|
this.loadPendingGeneralEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPendingGeneralEdit(): void {
|
||||||
|
if (!this.clientId) return;
|
||||||
|
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
|
||||||
|
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
|
||||||
|
error: () => (this.hasPendingGeneralEdit = false)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +205,61 @@ export class EditClientComponent {
|
|||||||
return { ...general };
|
return { ...general };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live view of `detectSensitiveFieldChanges()` against the loaded snapshot — drives the
|
||||||
|
* "Verify Contact Change" OTP panel in the General tab template. Re-evaluated on every
|
||||||
|
* change-detection pass (cheap: a handful of form-control reads), so the panel appears/
|
||||||
|
* disappears as the user types, same as the existing re-KYC name/DOB detection.
|
||||||
|
*/
|
||||||
|
get contactChanges() {
|
||||||
|
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
||||||
|
get contactVerificationComplete(): boolean {
|
||||||
|
const changes = this.contactChanges;
|
||||||
|
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOldMobileOtpState(state: ContactOtpState): void {
|
||||||
|
this.oldMobileOtpId = state.otpId;
|
||||||
|
this.oldMobileVerified = state.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewMobileOtpState(state: ContactOtpState): void {
|
||||||
|
this.newMobileOtpId = state.otpId;
|
||||||
|
this.newMobileVerified = state.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOldEmailOtpState(state: ContactOtpState): void {
|
||||||
|
this.oldEmailOtpId = state.otpId;
|
||||||
|
this.oldEmailVerified = state.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewEmailOtpState(state: ContactOtpState): void {
|
||||||
|
this.newEmailOtpId = state.otpId;
|
||||||
|
this.newEmailVerified = state.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all dual-OTP state — called once a General-tab save succeeds (the snapshot is
|
||||||
|
* updated to match, so the "Verify Contact Change" panel naturally disappears too). */
|
||||||
|
private resetContactVerificationState(): void {
|
||||||
|
this.oldMobileOtpId = null;
|
||||||
|
this.newMobileOtpId = null;
|
||||||
|
this.oldMobileVerified = false;
|
||||||
|
this.newMobileVerified = false;
|
||||||
|
this.oldEmailOtpId = null;
|
||||||
|
this.newEmailOtpId = null;
|
||||||
|
this.oldEmailVerified = false;
|
||||||
|
this.newEmailVerified = false;
|
||||||
|
}
|
||||||
|
|
||||||
areFormvalids(): boolean {
|
areFormvalids(): boolean {
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) {
|
if (!this.clientGeneralStep || !this.clientGeneralForm) {
|
||||||
return false;
|
return false;
|
||||||
@@ -196,7 +288,10 @@ export class EditClientComponent {
|
|||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||||
return this.clientGeneralForm.valid;
|
if (!this.clientGeneralForm.valid) return false;
|
||||||
|
// Dual-OTP gate: a changed mobile/email must be verified on both old & new
|
||||||
|
// destinations before Save is allowed — mirrors the re-KYC gate below it.
|
||||||
|
return this.contactVerificationComplete;
|
||||||
}
|
}
|
||||||
if (idx === 1) {
|
if (idx === 1) {
|
||||||
const kf = this.clientKycStep?.kycForm;
|
const kf = this.clientKycStep?.kycForm;
|
||||||
@@ -205,14 +300,20 @@ export class EditClientComponent {
|
|||||||
if (this.legalFormType === 2) {
|
if (this.legalFormType === 2) {
|
||||||
if (idx === 2) return true; // Scheme & Sector — opt-in
|
if (idx === 2) return true; // Scheme & Sector — opt-in
|
||||||
if (idx === 3) return this.isAddressStepValid();
|
if (idx === 3) return this.isAddressStepValid();
|
||||||
if (idx === 4) return true; // Family — optional
|
if (idx === 4) return true; // Family — not applicable to non-individual entities
|
||||||
} else {
|
} else {
|
||||||
if (idx === 2) return this.isAddressStepValid();
|
if (idx === 2) return this.isAddressStepValid();
|
||||||
if (idx === 3) return true; // Family — optional
|
if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mother is required, and either Father or a Caretaker, for individual clients. */
|
||||||
|
private isFamilyStepValid(): boolean {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
return !step || step.familyRelationshipRulesMet;
|
||||||
|
}
|
||||||
|
|
||||||
private isAddressStepValid(): boolean {
|
private isAddressStepValid(): boolean {
|
||||||
const step = this.clientIndiaAddressStep;
|
const step = this.clientIndiaAddressStep;
|
||||||
if (!step) return true;
|
if (!step) return true;
|
||||||
@@ -227,6 +328,21 @@ export class EditClientComponent {
|
|||||||
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
return 'Client failed sanctions screening — cannot proceed.';
|
return 'Client failed sanctions screening — cannot proceed.';
|
||||||
}
|
}
|
||||||
|
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
||||||
|
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
||||||
|
}
|
||||||
|
if (this.selectedTabIndex === 1) {
|
||||||
|
const labels = this.clientKycStep?.invalidFieldLabels || [];
|
||||||
|
if (labels.length) {
|
||||||
|
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
||||||
|
if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||||
|
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
return 'Please fill all mandatory fields on this tab to continue.';
|
return 'Please fill all mandatory fields on this tab to continue.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +388,16 @@ export class EditClientComponent {
|
|||||||
// Check if name or DOB changed on the General tab — warn about re-KYC
|
// Check if name or DOB changed on the General tab — warn about re-KYC
|
||||||
if (this.selectedTabIndex === 0) {
|
if (this.selectedTabIndex === 0) {
|
||||||
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||||
|
|
||||||
|
// Dual-OTP gate — defensive re-check. The "Save & Next" button is already disabled
|
||||||
|
// via `currentStepValid` while this is incomplete, but don't rely on that alone.
|
||||||
|
if (!this.contactVerificationComplete) {
|
||||||
|
this.snackBar.open('Verify the OTP sent to both the old and new mobile number/email before saving.', 'Close', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (changes.nameChanged || changes.dobChanged) {
|
if (changes.nameChanged || changes.dobChanged) {
|
||||||
heading = 'Re-KYC Required';
|
heading = 'Re-KYC Required';
|
||||||
dialogContext =
|
dialogContext =
|
||||||
@@ -308,39 +434,65 @@ export class EditClientComponent {
|
|||||||
const originalData = this.clientDataAndTemplate;
|
const originalData = this.clientDataAndTemplate;
|
||||||
const changedFields = this.detectSensitiveFieldChanges(originalData);
|
const changedFields = this.detectSensitiveFieldChanges(originalData);
|
||||||
|
|
||||||
|
// Dual-OTP verified ids — sent only for the field(s) that actually changed, per-field
|
||||||
|
// (not a single generic old/new pair) so the backend can tell mobile's OTPs apart from
|
||||||
|
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||||
|
// state fields for the full naming rationale.
|
||||||
|
if (changedFields.mobileChanged) {
|
||||||
|
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||||
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||||
|
}
|
||||||
|
if (changedFields.emailChanged) {
|
||||||
|
clientData.emailOldOtpId = this.oldEmailOtpId;
|
||||||
|
clientData.emailNewOtpId = this.newEmailOtpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
|
||||||
|
// client record loaded when this edit page opened, rather than relying on comparing against
|
||||||
|
// the LIVE client later (which would show nothing changed once a checker approves the edit,
|
||||||
|
// since by then current == proposed).
|
||||||
|
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
|
||||||
|
|
||||||
this.savingClient = true;
|
this.savingClient = true;
|
||||||
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
|
||||||
|
// side effects that a direct update would have had immediately — SMS/Email contact-change
|
||||||
|
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
|
||||||
|
// different user actually approves the edit (approval replays this exact payload through
|
||||||
|
// the same updateClient() the direct PUT used to call, so the backend-driven notification
|
||||||
|
// still fires correctly, just at approval time instead of now).
|
||||||
|
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
this.tabEditMode[tabIndex] = false;
|
this.tabEditMode[tabIndex] = false;
|
||||||
|
this.hasPendingGeneralEdit = true;
|
||||||
|
|
||||||
// Log mobile/email changes as client notes for risk audit
|
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
||||||
this.logFieldChangeNotes(changedFields);
|
// the staged JSON edit, so these still apply immediately as before.
|
||||||
|
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
const seniorCitizenProofForm = new FormData();
|
||||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||||
this.dobChanged = true;
|
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
||||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
||||||
this.flagReKycRequired(changedFields);
|
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: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update original data snapshot so subsequent edits compare against latest
|
this.snackBar.open(
|
||||||
if (changedFields.mobileChanged) {
|
'Edit submitted for approval — a different user must review it before it takes effect.',
|
||||||
originalData.mobileNo = changedFields.newMobile;
|
'Close',
|
||||||
}
|
{
|
||||||
if (changedFields.emailChanged) {
|
duration: 5000
|
||||||
originalData.emailAddress = changedFields.newEmail;
|
}
|
||||||
}
|
);
|
||||||
if (changedFields.dobChanged) {
|
this.resetContactVerificationState();
|
||||||
originalData.dateOfBirth = changedFields.newDob;
|
|
||||||
}
|
|
||||||
if (changedFields.nameChanged) {
|
|
||||||
const form = this.clientGeneralForm;
|
|
||||||
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
|
|
||||||
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
|
|
||||||
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
|
|
||||||
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||||
},
|
},
|
||||||
@@ -430,12 +582,14 @@ export class EditClientComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Address tab
|
// Address tab — no OTP gate (an address has no "old/new channel" to verify an OTP
|
||||||
|
// against); notification-only, per the plan. Save proceeds normally.
|
||||||
const addressTabIdx = this.legalFormType === 2 ? 3 : 2;
|
const addressTabIdx = this.legalFormType === 2 ? 3 : 2;
|
||||||
if (tabIndex === addressTabIdx) {
|
if (tabIndex === addressTabIdx) {
|
||||||
this.savingClient = true;
|
this.savingClient = true;
|
||||||
if (this.clientIndiaAddressStep?.hasAddressData) {
|
if (this.clientIndiaAddressStep?.hasAddressData) {
|
||||||
this.saveClientAddresses(this.clientId);
|
this.saveClientAddresses(this.clientId);
|
||||||
|
this.snackBar.open('Customer notified via SMS/Email of the address change.', 'Close', { duration: 4000 });
|
||||||
}
|
}
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
this.tabEditMode[tabIndex] = false;
|
this.tabEditMode[tabIndex] = false;
|
||||||
@@ -465,20 +619,41 @@ export class EditClientComponent {
|
|||||||
/**
|
/**
|
||||||
* Compare current form values with original data to detect changes in name, DOB, mobile, email.
|
* Compare current form values with original data to detect changes in name, DOB, mobile, email.
|
||||||
*/
|
*/
|
||||||
|
/** "YYYY-MM-DD" built from a Date's LOCAL components — see the timezone-safety note where
|
||||||
|
* this is called from `detectSensitiveFieldChanges()`. */
|
||||||
|
private toLocalDateString(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
const newMobile = form?.get('mobileNo')?.value || '';
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||||
const newEmail = form?.get('emailAddress')?.value || '';
|
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||||
|
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||||
|
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||||
|
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||||
|
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
||||||
|
// customer's email casing into the saved record or the OTP destination.
|
||||||
|
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
||||||
|
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
const newDobRaw = form?.get('dateOfBirth')?.value;
|
||||||
const newDob = newDobRaw instanceof Date ? newDobRaw.toISOString().split('T')[0] : newDobRaw || '';
|
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||||
|
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||||
|
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||||
|
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
||||||
|
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||||
|
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||||
|
|
||||||
const oldMobile = originalData.mobileNo || '';
|
const oldMobile = (originalData.mobileNo || '').trim();
|
||||||
const oldEmail = originalData.emailAddress || '';
|
const oldEmail = (originalData.emailAddress || '').trim();
|
||||||
const oldDobRaw = originalData.dateOfBirth;
|
const oldDobRaw = originalData.dateOfBirth;
|
||||||
const oldDob = Array.isArray(oldDobRaw)
|
const oldDob = Array.isArray(oldDobRaw)
|
||||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||||
: oldDobRaw instanceof Date
|
: oldDobRaw instanceof Date
|
||||||
? oldDobRaw.toISOString().split('T')[0]
|
? this.toLocalDateString(oldDobRaw)
|
||||||
: oldDobRaw || '';
|
: oldDobRaw || '';
|
||||||
|
|
||||||
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
||||||
@@ -509,7 +684,9 @@ export class EditClientComponent {
|
|||||||
return {
|
return {
|
||||||
nameChanged: newName !== oldName,
|
nameChanged: newName !== oldName,
|
||||||
mobileChanged: newMobile !== oldMobile,
|
mobileChanged: newMobile !== oldMobile,
|
||||||
emailChanged: newEmail !== oldEmail,
|
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||||
|
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||||
|
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
||||||
dobChanged: newDob !== oldDob,
|
dobChanged: newDob !== oldDob,
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared between the maker side (edit-client, which captures a point-in-time snapshot of the
|
||||||
|
* client's current values right before staging an edit) and the checker side (pending-client-edits,
|
||||||
|
* which diffs the staged payload against that snapshot). Keeping both in one place means the two
|
||||||
|
* sides can never drift out of sync on which fields are captured/compared/labelled.
|
||||||
|
*
|
||||||
|
* Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) —
|
||||||
|
* CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id
|
||||||
|
* to compare against the payload's raw id.
|
||||||
|
*/
|
||||||
|
export const PENDING_EDIT_FIELD_EXTRACTORS: Record<string, (c: any) => any> = {
|
||||||
|
officeId: (c) => c.officeId,
|
||||||
|
legalFormId: (c) => c.legalForm?.id,
|
||||||
|
nationalityId: (c) => c.nationality,
|
||||||
|
isStaff: (c) => c.isStaff,
|
||||||
|
active: (c) => c.active,
|
||||||
|
accountNo: (c) => c.accountNo,
|
||||||
|
salutationId: (c) => c.salutation?.id,
|
||||||
|
firstname: (c) => c.firstname,
|
||||||
|
middlename: (c) => c.middlename,
|
||||||
|
lastname: (c) => c.lastname,
|
||||||
|
fullname: (c) => c.fullname,
|
||||||
|
genderId: (c) => c.gender?.id,
|
||||||
|
maritalStatusId: (c) => c.maritalStatus?.id,
|
||||||
|
educationLevelId: (c) => c.educationLevel?.id,
|
||||||
|
degreeQualificationId: (c) => c.degreeQualification?.id,
|
||||||
|
religionId: (c) => c.religion?.id,
|
||||||
|
socialCategoryId: (c) => c.socialCategory?.id,
|
||||||
|
mobileNo: (c) => c.mobileNo,
|
||||||
|
alternateMobileNo: (c) => c.alternateMobileNo,
|
||||||
|
emailAddress: (c) => c.emailAddress,
|
||||||
|
dateOfBirth: (c) => c.dateOfBirth,
|
||||||
|
occupationCategoryId: (c) => c.occupationCategory?.id,
|
||||||
|
occupationId: (c) => c.occupation?.id,
|
||||||
|
monthlyIncome: (c) => c.monthlyIncome,
|
||||||
|
annualIncome: (c) => c.annualIncome,
|
||||||
|
annualTurnover: (c) => c.annualTurnover,
|
||||||
|
monthlyTurnover: (c) => c.monthlyTurnover,
|
||||||
|
isPwd: (c) => c.isPwd,
|
||||||
|
pwdCategoryId: (c) => c.pwdCategory?.id,
|
||||||
|
isSeniorCitizen: (c) => c.isSeniorCitizen,
|
||||||
|
agricultureIncome: (c) => c.agricultureIncome,
|
||||||
|
nonAgricultureIncome: (c) => c.nonAgricultureIncome,
|
||||||
|
savingsProductId: (c) => c.savingsProductId,
|
||||||
|
staffId: (c) => c.staffId,
|
||||||
|
guardianClientId: (c) => c.guardianClientId,
|
||||||
|
guardianRelation: (c) => c.guardianRelation
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a
|
||||||
|
* checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the
|
||||||
|
* business-date default on submittedOnDate) but with no comparable "current" value. */
|
||||||
|
export const NON_COMPARABLE_KEYS = new Set([
|
||||||
|
'dateFormat',
|
||||||
|
'locale',
|
||||||
|
'ageYears',
|
||||||
|
'ageMonths',
|
||||||
|
'ageDays',
|
||||||
|
'isMinor',
|
||||||
|
'addSavings',
|
||||||
|
'submittedOnDate',
|
||||||
|
'activationDate',
|
||||||
|
// Flattened onto top-level guardianClientId/guardianRelation right after this in the payload —
|
||||||
|
// that flattened pair already has real comparators above, so the nested group is redundant.
|
||||||
|
'guardianDetails',
|
||||||
|
// Mobile/email DIP verification + name-match + sanctions-screening results — carried along on
|
||||||
|
// every save to re-affirm the client's EXISTING verification state, not something the maker
|
||||||
|
// edited on this pass. dipTimestamp especially would almost never string-match exactly even
|
||||||
|
// when functionally unchanged, so these were never real "proposed changes" to review.
|
||||||
|
'dipVerified',
|
||||||
|
'dipLookupLogId',
|
||||||
|
'dipTimestamp',
|
||||||
|
'alternateDipVerified',
|
||||||
|
'alternateDipLookupLogId',
|
||||||
|
'alternateDipTimestamp',
|
||||||
|
'nameVerified',
|
||||||
|
'screeningLogId',
|
||||||
|
'mobileNewOtpId',
|
||||||
|
'mobileOldOtpId',
|
||||||
|
'emailNewOtpId',
|
||||||
|
'emailOldOtpId'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PENDING_EDIT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
officeId: 'Office',
|
||||||
|
legalFormId: 'Legal Form',
|
||||||
|
nationalityId: 'Nationality',
|
||||||
|
isStaff: 'Is Staff',
|
||||||
|
active: 'Active',
|
||||||
|
accountNo: 'Account Number',
|
||||||
|
salutationId: 'Salutation',
|
||||||
|
firstname: 'First Name',
|
||||||
|
middlename: 'Middle Name',
|
||||||
|
lastname: 'Last Name',
|
||||||
|
fullname: 'Full Name',
|
||||||
|
genderId: 'Gender',
|
||||||
|
maritalStatusId: 'Marital Status',
|
||||||
|
educationLevelId: 'Education Level',
|
||||||
|
degreeQualificationId: 'Degree / Qualification',
|
||||||
|
religionId: 'Religion',
|
||||||
|
socialCategoryId: 'Social Category',
|
||||||
|
mobileNo: 'Mobile Number',
|
||||||
|
alternateMobileNo: 'Alternate Mobile Number',
|
||||||
|
emailAddress: 'Email Address',
|
||||||
|
dateOfBirth: 'Date of Birth',
|
||||||
|
occupationCategoryId: 'Occupation Category',
|
||||||
|
occupationId: 'Occupation',
|
||||||
|
monthlyIncome: 'Monthly Income',
|
||||||
|
annualIncome: 'Annual Income',
|
||||||
|
annualTurnover: 'Yearly Turnover',
|
||||||
|
monthlyTurnover: 'Monthly Turnover',
|
||||||
|
isPwd: 'Person with Disability',
|
||||||
|
pwdCategoryId: 'Disability Type',
|
||||||
|
isSeniorCitizen: 'Senior Citizen',
|
||||||
|
agricultureIncome: 'Agriculture Income',
|
||||||
|
nonAgricultureIncome: 'Non-Agriculture Income',
|
||||||
|
submittedOnDate: 'Submitted On',
|
||||||
|
savingsProductId: 'Linked Savings Product',
|
||||||
|
staffId: 'Staff'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload —
|
||||||
|
* stripped out by the backend before replaying the rest through updateClient(), and stored
|
||||||
|
* separately as original_json for the checker's diff. Never a real Client field name, so it can't
|
||||||
|
* collide with anything ClientDataValidator actually expects. */
|
||||||
|
export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues';
|
||||||
|
|
||||||
|
/** Builds the maker-side "before" snapshot from the client record loaded when the edit page
|
||||||
|
* opened — flat key names matching the payload's own, so the checker side can diff two plain
|
||||||
|
* objects directly without needing to know Client's nested response shape at all. */
|
||||||
|
export function buildOriginalValuesSnapshot(clientData: any): Record<string, any> {
|
||||||
|
const snapshot: Record<string, any> = {};
|
||||||
|
if (!clientData) return snapshot;
|
||||||
|
for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) {
|
||||||
|
const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData);
|
||||||
|
if (value !== undefined) {
|
||||||
|
snapshot[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,20 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
|||||||
documentKeyPlaceholder: 'NPR reference number',
|
documentKeyPlaceholder: 'NPR reference number',
|
||||||
info: 'NPR letter — upload full document.'
|
info: 'NPR letter — upload full document.'
|
||||||
},
|
},
|
||||||
|
BIRTH_CERTIFICATE: {
|
||||||
|
sides: 1,
|
||||||
|
documentKeyLabel: 'Birth certificate registration number',
|
||||||
|
documentKeyPlaceholder: 'Registration / certificate number',
|
||||||
|
category: 'Government-issued ID',
|
||||||
|
info: 'Birth certificate issued by the Municipal Corporation / Registrar of Births & Deaths — accepted proof of identity for minor clients as per RBI KYC norms.'
|
||||||
|
},
|
||||||
|
SCHOOL_ID_CARD: {
|
||||||
|
sides: 1,
|
||||||
|
documentKeyLabel: 'School ID / admission number',
|
||||||
|
documentKeyPlaceholder: 'Student ID number',
|
||||||
|
category: 'Government-issued ID',
|
||||||
|
info: 'School identity card bearing photograph — accepted proof of identity for minor clients as per RBI KYC norms.'
|
||||||
|
},
|
||||||
UTILITY_BILL: {
|
UTILITY_BILL: {
|
||||||
sides: 1,
|
sides: 1,
|
||||||
category: 'Utility bills',
|
category: 'Utility bills',
|
||||||
@@ -131,7 +145,46 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
|||||||
PENSION_PAYMENT_ORDER: {
|
PENSION_PAYMENT_ORDER: {
|
||||||
sides: 1,
|
sides: 1,
|
||||||
category: 'Financial & other',
|
category: 'Financial & other',
|
||||||
|
requiresDate: true,
|
||||||
info: 'Pension payment order — upload full document issued by pension authority.'
|
info: 'Pension payment order — upload full document issued by pension authority.'
|
||||||
|
},
|
||||||
|
CERT_INCORPORATION: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'Registration / incorporation number',
|
||||||
|
documentKeyPlaceholder: 'CIN / registration number',
|
||||||
|
info: 'Certificate of Incorporation (companies/LLPs) or Registration Certificate (firms/trusts/societies) — as prescribed under the RBI Master Direction on KYC, Annexure I.'
|
||||||
|
},
|
||||||
|
ENTITY_PAN: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'PAN of entity',
|
||||||
|
documentKeyPlaceholder: 'e.g. ABCDE1234F',
|
||||||
|
info: 'PAN card issued in the name of the entity.'
|
||||||
|
},
|
||||||
|
CONSTITUTIVE_DOC: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'Document reference number',
|
||||||
|
documentKeyPlaceholder: 'Reference / registration number',
|
||||||
|
info: 'Constitutive document — Memorandum & Articles of Association (companies), Partnership Deed (firms), Trust Deed (trusts), or By-laws (societies) — as prescribed under the RBI Master Direction on KYC.'
|
||||||
|
},
|
||||||
|
BOARD_RESOLUTION: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
info: 'Board Resolution / Authorization Letter naming the officers, managers or employees authorised to transact on the entity’s behalf.'
|
||||||
|
},
|
||||||
|
AUTH_SIGNATORY_KYC: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
info: 'OVD-based KYC of the person(s) holding Power of Attorney / authorised to transact on the entity’s behalf.'
|
||||||
|
},
|
||||||
|
GST_CERTIFICATE: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'GSTIN',
|
||||||
|
documentKeyPlaceholder: 'e.g. 22AAAAA0000A1Z5',
|
||||||
|
info: 'GST Registration Certificate, where the entity is GST-registered.'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -281,12 +281,8 @@ export class AuthenticationService {
|
|||||||
message: 'Your password has expired, please reset your password!'
|
message: 'Your password has expired, please reset your password!'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setCredentials(credentials);
|
this.credentials = credentials;
|
||||||
this.alertService.alert({
|
this.finalizeLogin();
|
||||||
type: 'Authentication Success',
|
|
||||||
message: `${credentials.username} successfully logged in!`
|
|
||||||
});
|
|
||||||
delete this.credentials;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,6 +392,22 @@ export class AuthenticationService {
|
|||||||
return roleNames.some((n) => n === 'clerk');
|
return roleNames.some((n) => n === 'clerk');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
|
||||||
|
isPrincipalOfficerUser(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'principal officer');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
|
||||||
|
isAmlOfficerUser(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'aml officer');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true ONLY for regular cashier role (not Head Cashier).
|
* Returns true ONLY for regular cashier role (not Head Cashier).
|
||||||
* Regular cashier → full-screen transaction entry, no nav.
|
* Regular cashier → full-screen transaction entry, no nav.
|
||||||
@@ -420,6 +432,14 @@ export class AuthenticationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "Cashier" role. */
|
||||||
|
hasCashierRole(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'cashier');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the user credentials.
|
* Sets the user credentials.
|
||||||
*
|
*
|
||||||
@@ -546,16 +566,60 @@ export class AuthenticationService {
|
|||||||
message: 'Your password has expired, please reset your password!'
|
message: 'Your password has expired, please reset your password!'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setCredentials(this.credentials);
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Authentication Success',
|
|
||||||
message: `${this.credentials.username} successfully logged in!`
|
|
||||||
});
|
|
||||||
delete this.credentials;
|
|
||||||
this.storage.setItem(this.twoFactorAuthenticationTokenStorageKey, JSON.stringify(response));
|
this.storage.setItem(this.twoFactorAuthenticationTokenStorageKey, JSON.stringify(response));
|
||||||
|
this.finalizeLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs after primary login (and 2FA, if the account has it enrolled) succeeds. Every user must
|
||||||
|
* accept the bank's current agreement/disclaimer at least once — checks that here and either
|
||||||
|
* finishes the login immediately (already accepted, or nothing published yet) or fires an alert
|
||||||
|
* so `login.component.ts` can show the accept dialog.
|
||||||
|
*/
|
||||||
|
private finalizeLogin(): void {
|
||||||
|
this.http.get<{ accepted: boolean }>('/useragreements/active/status').subscribe({
|
||||||
|
next: (status) => {
|
||||||
|
if (status?.accepted) {
|
||||||
|
this.completeAuthentication();
|
||||||
|
} else {
|
||||||
|
this.alertService.alert({
|
||||||
|
type: 'Agreement Acceptance Required',
|
||||||
|
message: 'Please review and accept the agreement to continue.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Don't let an agreement-status hiccup block login entirely.
|
||||||
|
error: () => this.completeAuthentication()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finishes the login: persists credentials and fires the existing 'Authentication Success' alert. */
|
||||||
|
private completeAuthentication(): void {
|
||||||
|
this.setCredentials(this.credentials);
|
||||||
|
this.alertService.alert({
|
||||||
|
type: 'Authentication Success',
|
||||||
|
message: `${this.credentials.username} successfully logged in!`
|
||||||
|
});
|
||||||
|
delete this.credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the active agreement's text for display, when the current user hasn't accepted it yet. */
|
||||||
|
getAgreementStatus(): Observable<{
|
||||||
|
accepted: boolean;
|
||||||
|
id?: number;
|
||||||
|
version?: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
}> {
|
||||||
|
return this.http.get<any>('/useragreements/active/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the current user's acceptance of the active agreement, then finishes the login. */
|
||||||
|
acceptActiveAgreement(): Observable<void> {
|
||||||
|
return this.http.post<any>('/useragreements/active/accept', {}).pipe(map(() => this.completeAuthentication()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets the user's password and authenticates the user.
|
* Resets the user's password and authenticates the user.
|
||||||
* @param {any} passwordDetails New password.
|
* @param {any} passwordDetails New password.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
|
|||||||
import { Logger } from '../logger/logger.service';
|
import { Logger } from '../logger/logger.service';
|
||||||
import { AlertService } from '../alert/alert.service';
|
import { AlertService } from '../alert/alert.service';
|
||||||
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
||||||
|
import { SILENT_HTTP_ERROR } from './silent-error.token';
|
||||||
|
|
||||||
/** Initialize Logger */
|
/** Initialize Logger */
|
||||||
const log = new Logger('ErrorHandlerInterceptor');
|
const log = new Logger('ErrorHandlerInterceptor');
|
||||||
@@ -54,12 +55,33 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||||
const isDirectorRequest = request.url.includes('/election/directors');
|
const isDirectorRequest = request.url.includes('/election/directors');
|
||||||
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
|
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
|
||||||
|
// load, not a real error the user needs to see.
|
||||||
|
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
|
||||||
|
// Risk rules are an enhancement on top of a default risk-category fallback — if this
|
||||||
|
// endpoint isn't available on a given backend deployment, the client-general-step
|
||||||
|
// component already falls back to riskRules = [] silently; no toast needed here either.
|
||||||
|
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
|
||||||
|
const isSilentRequest =
|
||||||
|
isClientImageRequest ||
|
||||||
|
isClientKycRequest ||
|
||||||
|
isDirectorRequest ||
|
||||||
|
isClientPendingEditRequest ||
|
||||||
|
isKycRiskRulesRequest;
|
||||||
|
|
||||||
if (!environment.production && !isSilentRequest) {
|
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
|
||||||
|
// component already recovers via its own catchError) — silenced for every status, since
|
||||||
|
// the caller took responsibility for handling failure when it set this.
|
||||||
|
const isSilentOverride = request.context.get(SILENT_HTTP_ERROR);
|
||||||
|
|
||||||
|
if (!environment.production && !isSilentRequest && !isSilentOverride) {
|
||||||
log.error(`Request Error: ${errorMessage}`);
|
log.error(`Request Error: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSilentOverride) {
|
||||||
|
throw response;
|
||||||
|
}
|
||||||
|
|
||||||
// Silently handle expected 403/404 errors (no image, no KYC record)
|
// Silently handle expected 403/404 errors (no image, no KYC record)
|
||||||
// Throw instead of EMPTY so component-level catchError can handle it
|
// Throw instead of EMPTY so component-level catchError can handle it
|
||||||
if (isSilentRequest && (status === 403 || status === 404)) {
|
if (isSilentRequest && (status === 403 || status === 404)) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request opt-out of ErrorHandlerInterceptor's toast. Set via
|
||||||
|
* `{ context: new HttpContext().set(SILENT_HTTP_ERROR, true) }` on calls whose caller already
|
||||||
|
* handles failures itself (e.g. per-row enrichment fetches on a list page) — the interceptor
|
||||||
|
* still throws so the caller's own catchError/error callback runs, it just skips the alert.
|
||||||
|
*/
|
||||||
|
export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => false);
|
||||||
@@ -1121,7 +1121,7 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
@@ -1167,10 +1167,6 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/fiu" tabindex="0">
|
|
||||||
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
|
|
||||||
Unit (FIU-IND)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Investment -->
|
<!-- Investment -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
@@ -1468,13 +1464,13 @@
|
|||||||
Balance Certificate
|
Balance Certificate
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 60
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 61
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 15G and H
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
||||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||||
@@ -1568,8 +1564,13 @@
|
|||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
|
<a
|
||||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
||||||
|
routerLink="/fiu"
|
||||||
|
(click)="rbiMenuOpen = false"
|
||||||
|
>
|
||||||
|
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
||||||
@@ -1664,8 +1665,13 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
|
<a
|
||||||
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesRbiSearch('AML')"
|
||||||
|
routerLink="/rbi/aml"
|
||||||
|
(click)="rbiMenuOpen = false"
|
||||||
|
>
|
||||||
|
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- OSS Reports -->
|
<!-- OSS Reports -->
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="cur-view">
|
<div class="cur-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="cur-hero">
|
<header class="cur-hero">
|
||||||
<div class="cur-hero__left">
|
<div class="cur-hero__left">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { AccountsService } from 'app/accounts/accounts.service';
|
import { AccountsService } from 'app/accounts/accounts.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
|
||||||
/** Environment Configuration */
|
/** Environment Configuration */
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
@@ -48,6 +49,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current Account View Component
|
* Current Account View Component
|
||||||
@@ -77,7 +79,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatTabNavPanel,
|
MatTabNavPanel,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CurrentAccountViewComponent implements OnInit {
|
export class CurrentAccountViewComponent implements OnInit {
|
||||||
@@ -89,6 +92,7 @@ export class CurrentAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** Current Account Data */
|
/** Current Account Data */
|
||||||
@@ -271,6 +275,14 @@ export class CurrentAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="fd-view">
|
<div class="fd-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="fixedDepositsAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="fd-hero">
|
<header class="fd-hero">
|
||||||
<div class="fd-hero__left">
|
<div class="fd-hero__left">
|
||||||
|
|||||||
+13
-1
@@ -27,6 +27,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
|
|||||||
import { Currency } from 'app/shared/models/general.model';
|
import { Currency } from 'app/shared/models/general.model';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { NgClass, CurrencyPipe } from '@angular/common';
|
import { NgClass, CurrencyPipe } from '@angular/common';
|
||||||
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
||||||
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed Deposits Account View Component
|
* Fixed Deposits Account View Component
|
||||||
@@ -62,7 +64,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatTabNavPanel,
|
MatTabNavPanel,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FixedDepositAccountViewComponent implements OnInit {
|
export class FixedDepositAccountViewComponent implements OnInit {
|
||||||
@@ -73,6 +76,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** Fixed Deposits Account Data */
|
/** Fixed Deposits Account Data */
|
||||||
@@ -194,6 +198,14 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="rd-view">
|
<div class="rd-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="recurringDepositsAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="rd-hero">
|
<header class="rd-hero">
|
||||||
<div class="rd-hero__left">
|
<div class="rd-hero__left">
|
||||||
|
|||||||
+13
-1
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { AccountsService } from 'app/accounts/accounts.service';
|
import { AccountsService } from 'app/accounts/accounts.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
|
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
|
||||||
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
||||||
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RecurringDeposits Account View Component
|
* RecurringDeposits Account View Component
|
||||||
@@ -63,7 +65,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class RecurringDepositsAccountViewComponent implements OnInit {
|
export class RecurringDepositsAccountViewComponent implements OnInit {
|
||||||
@@ -74,6 +77,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
|
||||||
@@ -245,6 +249,14 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -129,9 +129,130 @@
|
|||||||
<!-- CTR -->
|
<!-- CTR -->
|
||||||
@if (activeTab === 'ctr') {
|
@if (activeTab === 'ctr') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>CTR — Cash Transaction Report</h3>
|
<h3>Live Cash Alerts</h3>
|
||||||
|
<p>
|
||||||
|
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="RAISED">Raised</option>
|
||||||
|
<option value="UNDER_REVIEW">Under Review</option>
|
||||||
|
<option value="WHITELISTED">Whitelisted</option>
|
||||||
|
<option value="ESCALATED">Escalated</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Rule</th>
|
||||||
|
<th class="ta-r">Amount</th>
|
||||||
|
<th>Raised On</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (alert of amlAlerts; track alert.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ alert.id }}</td>
|
||||||
|
<td>{{ alert.clientDisplayName }}</td>
|
||||||
|
<td>{{ alert.transactionType }}</td>
|
||||||
|
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||||
|
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||||
|
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="fiu-inp"
|
||||||
|
placeholder="Review notes"
|
||||||
|
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||||
|
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||||
|
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>Monthly CTR Reports</h3>
|
||||||
|
<p>
|
||||||
|
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
||||||
|
per month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-action-group">
|
||||||
|
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
||||||
|
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th>Generated On</th>
|
||||||
|
<th class="ta-c">Records</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Submitted On</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of amlCtrReports; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.reportMonth }}</td>
|
||||||
|
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||||
|
<td class="ta-c">{{ r.recordCount }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>FINnet TC2 Export Template</h3>
|
||||||
<p>
|
<p>
|
||||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||||
</p>
|
</p>
|
||||||
@@ -221,9 +342,107 @@
|
|||||||
<!-- STR -->
|
<!-- STR -->
|
||||||
@if (activeTab === 'str') {
|
@if (activeTab === 'str') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>STR — Suspicious Transaction Report</h3>
|
<h3>Live STR Cases</h3>
|
||||||
|
<p>
|
||||||
|
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
|
||||||
|
working days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="ta-c">Cumulative Cases</th>
|
||||||
|
<th>Assigned PO</th>
|
||||||
|
<th>Conclusion</th>
|
||||||
|
<th>FIU Due Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (sc of amlStrCases; track sc.id) {
|
||||||
|
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||||
|
<td>{{ sc.id }}</td>
|
||||||
|
<td>{{ sc.clientDisplayName }}</td>
|
||||||
|
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
|
||||||
|
{{ sc.cumulativeStrCaseCountForClient }}
|
||||||
|
</td>
|
||||||
|
<td>{{ sc.assignedPoUsername || '—' }}</td>
|
||||||
|
<td>{{ sc.conclusion || '—' }}</td>
|
||||||
|
<td>{{ sc.fiuDueDate || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedStrCase) {
|
||||||
|
<div class="str-case-detail">
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||||
|
<p>
|
||||||
|
Alert #{{ selectedStrCase.alertId }} ·
|
||||||
|
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||||
|
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||||
|
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||||
|
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||||
|
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row conclusion-row">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||||
|
Not suspicious (whitelist)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||||
|
Suspicious (file STR)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
|
||||||
|
|
||||||
|
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||||
|
<div class="file-str-row">
|
||||||
|
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||||
|
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>FINnet STR XML Export Template</h3>
|
||||||
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||||
|
|||||||
@@ -579,6 +579,41 @@
|
|||||||
background: var(--f-red-l);
|
background: var(--f-red-l);
|
||||||
color: var(--f-red);
|
color: var(--f-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.status-raised {
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
color: var(--f-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-under-review {
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
color: var(--f-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-whitelisted,
|
||||||
|
&.status-concluded-clear,
|
||||||
|
&.status-generated,
|
||||||
|
&.status-submitted {
|
||||||
|
background: var(--f-green-l);
|
||||||
|
color: var(--f-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-escalated,
|
||||||
|
&.status-open,
|
||||||
|
&.status-concluded-suspicious {
|
||||||
|
background: var(--f-red-l);
|
||||||
|
color: var(--f-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-filed {
|
||||||
|
background: var(--f-purple-l);
|
||||||
|
color: var(--f-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-draft {
|
||||||
|
background: var(--f-g100);
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-pill {
|
.risk-pill {
|
||||||
@@ -892,3 +927,85 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-row {
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.escalation-warning {
|
||||||
|
color: var(--f-red);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.escalation-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
color: var(--f-amber);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.str-case-detail {
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-row {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-str-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--f-g300);
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { AmlService } from 'app/aml/aml.service';
|
||||||
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
import {
|
import {
|
||||||
CtrTransaction,
|
CtrTransaction,
|
||||||
CTR_TXNS,
|
CTR_TXNS,
|
||||||
@@ -33,7 +35,9 @@ import {
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FiuComponent {
|
export class FiuComponent implements OnInit {
|
||||||
|
private amlService = inject(AmlService);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||||
|
|
||||||
@@ -57,6 +61,126 @@ export class FiuComponent {
|
|||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: any;
|
private toastTimer: any;
|
||||||
|
|
||||||
|
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||||
|
amlAlerts: any[] = [];
|
||||||
|
amlAlertStatusFilter = '';
|
||||||
|
amlReviewNotes: Record<number, string> = {};
|
||||||
|
|
||||||
|
amlCtrReports: any[] = [];
|
||||||
|
ctrGenerateMonth = '';
|
||||||
|
|
||||||
|
amlStrCases: any[] = [];
|
||||||
|
selectedStrCase: any = null;
|
||||||
|
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||||
|
concludeInvestigationNotes = '';
|
||||||
|
concludeSourceOfFundsNotes = '';
|
||||||
|
fileReferenceNo = '';
|
||||||
|
|
||||||
|
get isPrincipalOfficer(): boolean {
|
||||||
|
return this.authenticationService.isPrincipalOfficerUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAmlOfficer(): boolean {
|
||||||
|
return this.authenticationService.isAmlOfficerUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
this.loadAmlCtrReports();
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlAlerts(): void {
|
||||||
|
this.amlService
|
||||||
|
.getAlerts(this.amlAlertStatusFilter || undefined)
|
||||||
|
.subscribe((data) => (this.amlAlerts = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlCtrReports(): void {
|
||||||
|
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlStrCases(): void {
|
||||||
|
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
whitelistAlert(alert: any): void {
|
||||||
|
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||||
|
this.toast(`Alert #${alert.id} whitelisted`);
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escalateAlert(alert: any): void {
|
||||||
|
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||||
|
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCtrReport(): void {
|
||||||
|
if (!this.ctrGenerateMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
|
||||||
|
this.loadAmlCtrReports();
|
||||||
|
},
|
||||||
|
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStrCase(strCase: any): void {
|
||||||
|
this.selectedStrCase = strCase;
|
||||||
|
this.concludeConclusion = 'NOT_SUSPICIOUS';
|
||||||
|
this.concludeInvestigationNotes = strCase.investigationNotes || '';
|
||||||
|
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
||||||
|
this.fileReferenceNo = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeStrCaseDetail(): void {
|
||||||
|
this.selectedStrCase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
concludeStrCase(): void {
|
||||||
|
if (!this.selectedStrCase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService
|
||||||
|
.concludeStrCase(
|
||||||
|
this.selectedStrCase.id,
|
||||||
|
this.concludeConclusion,
|
||||||
|
this.concludeInvestigationNotes,
|
||||||
|
this.concludeSourceOfFundsNotes
|
||||||
|
)
|
||||||
|
.subscribe((updated) => {
|
||||||
|
this.toast(
|
||||||
|
this.concludeConclusion === 'SUSPICIOUS'
|
||||||
|
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
|
||||||
|
: `STR case #${updated.id} concluded not suspicious`
|
||||||
|
);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStrCase(): void {
|
||||||
|
if (!this.selectedStrCase || !this.fileReferenceNo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
|
||||||
|
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
amlStatusClass(status: string): string {
|
||||||
|
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const routes: Routes = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
offices: OfficesResolver
|
offices: OfficesResolver
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'shares',
|
||||||
|
loadChildren: () => import('../shares/shares.module').then((m) => m.SharesModule)
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -544,82 +544,315 @@ export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyIntere
|
|||||||
|
|
||||||
// ───────────────────── Share Certificate ─────────────────────
|
// ───────────────────── Share Certificate ─────────────────────
|
||||||
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
|
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
|
||||||
const c = newCtx(ACCENT_AMBER);
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||||
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
|
const pageH = doc.internal.pageSize.getHeight();
|
||||||
|
const margin = 20;
|
||||||
|
const contentW = pageW - 40;
|
||||||
|
|
||||||
|
const gold: [number, number, number] = [
|
||||||
|
197,
|
||||||
|
160,
|
||||||
|
89
|
||||||
|
]; // #c5a059
|
||||||
|
const darkBlue: [number, number, number] = [
|
||||||
|
11,
|
||||||
|
60,
|
||||||
|
93
|
||||||
|
]; // #0b3c5d
|
||||||
|
const textDark: [number, number, number] = [
|
||||||
|
44,
|
||||||
|
37,
|
||||||
|
32
|
||||||
|
]; // #2c2520
|
||||||
|
const textMuted: [number, number, number] = [
|
||||||
|
123,
|
||||||
|
111,
|
||||||
|
99
|
||||||
|
]; // #7b6f63
|
||||||
|
const lightBg: [number, number, number] = [
|
||||||
|
253,
|
||||||
|
252,
|
||||||
|
247
|
||||||
|
]; // #fdfcf7
|
||||||
|
const containerBg: [number, number, number] = [
|
||||||
|
250,
|
||||||
|
248,
|
||||||
|
245
|
||||||
|
]; // #faf8f5
|
||||||
|
|
||||||
|
// 1. Draw Cream Background (page body)
|
||||||
|
doc.setFillColor(containerBg[0], containerBg[1], containerBg[2]);
|
||||||
|
doc.rect(0, 0, pageW, pageH, 'F');
|
||||||
|
|
||||||
|
// 2. Draw Textured cream background inside the outer border
|
||||||
|
doc.setFillColor(lightBg[0], lightBg[1], lightBg[2]);
|
||||||
|
doc.rect(12, 12, pageW - 24, pageH - 24, 'F');
|
||||||
|
|
||||||
|
// 3. Draw Gold Double Borders (Outer and Inner)
|
||||||
|
doc.setDrawColor(gold[0], gold[1], gold[2]);
|
||||||
|
|
||||||
|
// Outer double line
|
||||||
|
doc.setLineWidth(1.2);
|
||||||
|
doc.rect(12, 12, pageW - 24, pageH - 24, 'D');
|
||||||
|
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.rect(13.8, 13.8, pageW - 27.6, pageH - 27.6, 'D');
|
||||||
|
|
||||||
|
// Thin interior border line
|
||||||
|
doc.setDrawColor(220, 209, 189); // light brown/grey
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.rect(17, 17, pageW - 34, pageH - 34, 'D');
|
||||||
|
|
||||||
|
// 4. Certificate Header
|
||||||
|
let y = 28;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(22);
|
||||||
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
|
doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 5;
|
||||||
|
doc.setFont('times', 'italic');
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 4;
|
||||||
|
doc.setDrawColor(242, 237, 213); // light gold separator
|
||||||
|
doc.setLineWidth(1.5);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
y += 9;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(24);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
doc.text('SHARE CERTIFICATE', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 5.5;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('ISSUED UNDER THE COMMON SEAL OF THE BANK', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 4;
|
||||||
|
doc.setDrawColor(242, 237, 213); // separator
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
// 5. Meta Row (Certificate No, Folio No, Date of Issue)
|
||||||
|
y += 7;
|
||||||
|
|
||||||
|
// Column 1: Certificate No
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('CERTIFICATE NO.', 25, y);
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(r.certificateNo, 25, y + 4.5);
|
||||||
|
|
||||||
|
// Column 2: Folio No (Centered)
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('FOLIO NO.', pageW / 2, y, { align: 'center' });
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(r.folioNo, pageW / 2, y + 4.5, { align: 'center' });
|
||||||
|
|
||||||
|
// Column 3: Date of Issue (Right Aligned)
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('DATE OF ISSUE', pageW - 25, y, { align: 'right' });
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(formatLongDate(r.issueDate), pageW - 25, y + 4.5, { align: 'right' });
|
||||||
|
|
||||||
|
y += 8;
|
||||||
|
doc.setDrawColor(242, 237, 213);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
// 6. Centered Body Section
|
||||||
|
y += 9;
|
||||||
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
|
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
|
||||||
|
|
||||||
let y = paintLetterhead(c, 'Share Certificate', 'CERTIFICATE REFERENCE', r.certificateNo, 'Folio No.', r.folioNo);
|
doc.setFont('times', 'normal');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
|
||||||
y = paintAddressee(c, y, 'Issued To,', r.memberName, r.memberId, r.memberPan, r.memberAddress);
|
const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
|
||||||
|
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
|
||||||
// Centered, dramatic body paragraph (mirrors the highlighted "centered" style in the preview)
|
for (const line of bodyLines) {
|
||||||
if (y > c.pageH - c.margin - 80) {
|
doc.text(line, pageW / 2, y, { align: 'center' });
|
||||||
c.doc.addPage();
|
y += 5.5;
|
||||||
setFill(c, c.accent);
|
|
||||||
c.doc.rect(0, 0, c.pageW, 4, 'F');
|
|
||||||
y = c.margin;
|
|
||||||
}
|
}
|
||||||
const ctaText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${BANK_NAME}, subject to the bye-laws of the bank.`;
|
|
||||||
const ctaLines = c.doc.splitTextToSize(ctaText, c.contentW - 10);
|
|
||||||
const ctaH = ctaLines.length * 5 + 6;
|
|
||||||
setFill(c, c.accentTint);
|
|
||||||
c.doc.rect(c.margin, y, c.contentW, ctaH, 'F');
|
|
||||||
setFill(c, c.accent);
|
|
||||||
c.doc.rect(c.margin, y, 1.8, ctaH, 'F');
|
|
||||||
c.doc.setFont('times', 'bold');
|
|
||||||
c.doc.setFontSize(11);
|
|
||||||
setText(c, C_TEXT);
|
|
||||||
c.doc.text(ctaLines, c.margin + 6, y + 5, { align: 'justify', maxWidth: c.contentW - 10 });
|
|
||||||
y += ctaH + 6;
|
|
||||||
|
|
||||||
y = paintSummary(
|
// 7. ISSUED TO Box
|
||||||
c,
|
y += 3;
|
||||||
y,
|
const addrLines = doc.splitTextToSize(r.memberAddress || '', pageW - 60);
|
||||||
[
|
const boxH = 26 + addrLines.length * 4;
|
||||||
[
|
|
||||||
'CERTIFICATE NO.',
|
doc.setFillColor(253, 252, 247);
|
||||||
r.certificateNo,
|
doc.setDrawColor(230, 225, 210);
|
||||||
'FOLIO NO.',
|
doc.rect(25, y, pageW - 50, boxH, 'FD');
|
||||||
r.folioNo
|
|
||||||
],
|
let boxY = y + 6;
|
||||||
[
|
doc.setFont('helvetica', 'bold');
|
||||||
'BRANCH',
|
doc.setFontSize(8);
|
||||||
r.branch,
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
'MEMBER SINCE',
|
doc.text('ISSUED TO:', 30, boxY);
|
||||||
formatLongDate(r.joiningDate)
|
|
||||||
],
|
boxY += 5.5;
|
||||||
[
|
doc.setFont('times', 'bold');
|
||||||
'SHARE CLASS',
|
doc.setFontSize(13);
|
||||||
classLabel,
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
'DATE OF ISSUE',
|
doc.text(r.memberName, 30, boxY);
|
||||||
formatLongDate(r.issueDate)
|
|
||||||
],
|
boxY += 5.5;
|
||||||
[
|
doc.setFont('helvetica', 'normal');
|
||||||
'SHARE NUMBERS',
|
doc.setFontSize(9);
|
||||||
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
'DIVIDEND PAID',
|
let idLine = `Member ID: ${r.memberId}`;
|
||||||
`Rs. ${r.dividendYtd.toLocaleString('en-IN')}`
|
if (r.memberPan && r.memberPan !== 'Not Available' && r.memberPan !== '—') {
|
||||||
]
|
idLine += ` · PAN: ${r.memberPan}`;
|
||||||
],
|
}
|
||||||
{
|
doc.text(idLine, 30, boxY);
|
||||||
label: 'TOTAL FACE VALUE',
|
|
||||||
value: `Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`
|
boxY += 5;
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text(addrLines, 30, boxY);
|
||||||
|
|
||||||
|
y += boxH + 8;
|
||||||
|
|
||||||
|
// 8. Details Grid
|
||||||
|
const tableW = pageW - 50;
|
||||||
|
const tableColW = tableW / 4;
|
||||||
|
const tableRowH = 8.5;
|
||||||
|
|
||||||
|
const drawTableCell = (
|
||||||
|
x: number,
|
||||||
|
tblY: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
text: string,
|
||||||
|
isLabel: boolean,
|
||||||
|
align: 'left' | 'right' = 'left'
|
||||||
|
) => {
|
||||||
|
if (isLabel) {
|
||||||
|
doc.setFillColor(253, 252, 247);
|
||||||
|
doc.rect(x, tblY, w, h, 'F');
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
} else {
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
}
|
}
|
||||||
);
|
doc.setDrawColor(220, 209, 189);
|
||||||
|
doc.setLineWidth(0.25);
|
||||||
|
doc.rect(x, tblY, w, h, 'D');
|
||||||
|
doc.text(text, align === 'right' ? x + w - 3 : x + 3, tblY + h / 2 + 1.2, { align });
|
||||||
|
};
|
||||||
|
|
||||||
y = paintPara(
|
// Row 1
|
||||||
c,
|
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
|
||||||
|
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
|
// Row 2
|
||||||
|
drawTableCell(25, y, tableColW, tableRowH, 'Share Class', true);
|
||||||
|
drawTableCell(25 + tableColW, y, tableColW, tableRowH, classLabel, false);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Date of Issue', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.issueDate), false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
|
// Row 3
|
||||||
|
drawTableCell(25, y, tableColW, tableRowH, 'Share Numbers', true);
|
||||||
|
drawTableCell(
|
||||||
|
25 + tableColW,
|
||||||
y,
|
y,
|
||||||
`Given under the common seal of the bank this ${new Date().toLocaleDateString('en-IN', {
|
tableColW,
|
||||||
day: 'numeric',
|
tableRowH,
|
||||||
month: 'long',
|
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
|
||||||
year: 'numeric'
|
false
|
||||||
})}.`
|
|
||||||
);
|
);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Dividend Paid', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, `Rs. ${r.dividendYtd.toLocaleString('en-IN')}`, false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
y = paintSignoff(c, y + 6, r.branch);
|
// Row 4 (Highlight)
|
||||||
paintFooter(c, y + 4);
|
doc.setFillColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
return c.doc;
|
doc.rect(25, y, tableColW * 2, tableRowH, 'F');
|
||||||
|
doc.rect(25 + tableColW * 2, y, tableColW * 2, tableRowH, 'F');
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('TOTAL FACE VALUE', 28, y + tableRowH / 2 + 1.2);
|
||||||
|
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.text(`Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`, pageW - 28, y + tableRowH / 2 + 1.2, {
|
||||||
|
align: 'right'
|
||||||
|
});
|
||||||
|
y += tableRowH + 8;
|
||||||
|
|
||||||
|
// 9. Footer Text (Given under common seal...)
|
||||||
|
doc.setFont('times', 'italic');
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
|
||||||
|
const sealDay = new Date().getDate();
|
||||||
|
const sealMonthYear = new Date().toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
|
||||||
|
const sealPhrase = `Given under the common seal of the bank this ${sealDay} day of ${sealMonthYear}.`;
|
||||||
|
doc.text(sealPhrase, pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
// 10. Seal Circle (left) and Signatory block (right)
|
||||||
|
const sealX = 50;
|
||||||
|
const sealY = y + 14;
|
||||||
|
doc.setDrawColor(gold[0], gold[1], gold[2]); // Gold
|
||||||
|
doc.setLineWidth(0.6);
|
||||||
|
doc.circle(sealX, sealY, 14, 'D');
|
||||||
|
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setLineDashPattern(
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
doc.circle(sealX, sealY, 11, 'D');
|
||||||
|
doc.setLineDashPattern([], 0);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
doc.text('COMMON', sealX, sealY - 1, { align: 'center' });
|
||||||
|
doc.text('SEAL', sealX, sealY + 3.5, { align: 'center' });
|
||||||
|
|
||||||
|
// Signatory Line
|
||||||
|
doc.setDrawColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(pageW - 75, y + 20, pageW - 25, y + 20);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text('Authorised Signatory', pageW - 50, y + 25, { align: 'center' });
|
||||||
|
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
|
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
|
||||||
@@ -736,7 +969,7 @@ export function buildForm60Pdf(r: Form60Record): jsPDF {
|
|||||||
|
|
||||||
let y = paintLetterhead(
|
let y = paintLetterhead(
|
||||||
c,
|
c,
|
||||||
'Form 60 Declaration',
|
'Form 97 Declaration',
|
||||||
'CERTIFICATE REFERENCE',
|
'CERTIFICATE REFERENCE',
|
||||||
`F60-${r.customerId}`,
|
`F60-${r.customerId}`,
|
||||||
'PAN Status',
|
'PAN Status',
|
||||||
|
|||||||
+3
-3
@@ -85,7 +85,7 @@
|
|||||||
} @else if (isBalance && balance) {
|
} @else if (isBalance && balance) {
|
||||||
<span class="cpd-stage-pill">{{ balance.asOnDate | date: 'd MMM y' }}</span>
|
<span class="cpd-stage-pill">{{ balance.asOnDate | date: 'd MMM y' }}</span>
|
||||||
} @else if (isForm60 && form60) {
|
} @else if (isForm60 && form60) {
|
||||||
<span class="cpd-stage-pill cpd-stage-pill--amber">No PAN — Form 60</span>
|
<span class="cpd-stage-pill cpd-stage-pill--amber">No PAN — Form 97</span>
|
||||||
} @else if (isForm15gh && form15gh) {
|
} @else if (isForm15gh && form15gh) {
|
||||||
<span class="cpd-stage-pill cpd-stage-pill--teal">Form {{ form15gh.kind }}</span>
|
<span class="cpd-stage-pill cpd-stage-pill--teal">Form {{ form15gh.kind }}</span>
|
||||||
} @else if (isHypothecation && hypothecation) {
|
} @else if (isHypothecation && hypothecation) {
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ── FORM 60 ── -->
|
<!-- ── FORM 97 ── -->
|
||||||
@if (isForm60 && form60) {
|
@if (isForm60 && form60) {
|
||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
Declaration under <strong>Section 114B</strong> of the Income-tax Rules, 1962, to be made by a person who does
|
Declaration under <strong>Section 114B</strong> of the Income-tax Rules, 1962, to be made by a person who does
|
||||||
@@ -367,7 +367,7 @@
|
|||||||
<tr class="cpd-summary-total">
|
<tr class="cpd-summary-total">
|
||||||
<td colspan="3">PAN STATUS</td>
|
<td colspan="3">PAN STATUS</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>NOT ALLOTTED — Form 60 declared</strong>
|
<strong>NOT ALLOTTED — Form 97 declared</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+2
-2
@@ -151,9 +151,9 @@ export class CertificatePreviewDialogComponent {
|
|||||||
case 'balance':
|
case 'balance':
|
||||||
return 'Balance Certificate';
|
return 'Balance Certificate';
|
||||||
case 'form60':
|
case 'form60':
|
||||||
return 'Form 60 Declaration';
|
return 'Form 97 Declaration';
|
||||||
case 'form15gh':
|
case 'form15gh':
|
||||||
return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration';
|
return 'Form 121 Declaration';
|
||||||
case 'hypothecation':
|
case 'hypothecation':
|
||||||
return 'Hypothecation Certificate';
|
return 'Hypothecation Certificate';
|
||||||
case 'noc':
|
case 'noc':
|
||||||
|
|||||||
@@ -89,6 +89,43 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cert-head-r {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-cta-btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--cert-blue-d);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--cert-blue-d);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cert-blue);
|
||||||
|
border-color: var(--cert-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--cert-g700);
|
||||||
|
border-color: var(--cert-g200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cert-g50);
|
||||||
|
border-color: var(--cert-g300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── KPI strip ───────── */
|
/* ───────── KPI strip ───────── */
|
||||||
.cert-kpis {
|
.cert-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -11,11 +11,8 @@
|
|||||||
<div class="cert-head-l">
|
<div class="cert-head-l">
|
||||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Form 15G & 15H</h2>
|
<h2 class="cert-head-t">Form 121</h2>
|
||||||
<div class="cert-head-s">
|
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
|
||||||
TDS-waiver declarations for FD / RD interest income —
|
|
||||||
<strong>15G</strong> for individuals below 60 years, <strong>15H</strong> for senior citizens (60+).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,14 +23,14 @@
|
|||||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 15G (under 60)</div>
|
<div class="cert-kpi-l">Form 121 (under 60)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 15H (60+)</div>
|
<div class="cert-kpi-l">Form 121 (60+)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--green">
|
<div class="cert-kpi cert-kpi--green">
|
||||||
@@ -63,11 +60,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<div class="cert-filter">
|
||||||
<label>Form</label>
|
<label>Form Type</label>
|
||||||
<select [(ngModel)]="kindFilter">
|
<select [(ngModel)]="kindFilter">
|
||||||
<option value="">Both forms</option>
|
<option value="">All Form 121</option>
|
||||||
<option value="15G">15G — Under 60</option>
|
<option value="15G">Form 121 (Below 60 years)</option>
|
||||||
<option value="15H">15H — Senior citizens (60+)</option>
|
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,7 +89,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Customer · PAN</th>
|
<th>Customer · PAN</th>
|
||||||
<th>Form</th>
|
<th>Form Type</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>Branch</th>
|
<th>Branch</th>
|
||||||
<th class="num">Deposit Principal</th>
|
<th class="num">Deposit Principal</th>
|
||||||
@@ -115,7 +112,7 @@
|
|||||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
[class.cert-pill--blue]="formKind(r) === '15G'"
|
||||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
[class.cert-pill--amber]="formKind(r) === '15H'"
|
||||||
>
|
>
|
||||||
Form {{ formKind(r) }}
|
Form 121 ({{ formKind(r) }})
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
<td class="small">{{ age(r.dob) }} yrs</td>
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ export class Form15ghComponent implements OnInit {
|
|||||||
const kind = this.formKind(r);
|
const kind = this.formKind(r);
|
||||||
this.dscSign.promptThenDownload(
|
this.dscSign.promptThenDownload(
|
||||||
{
|
{
|
||||||
title: `Form ${kind} Declaration`,
|
title: `Form 121 (${kind}) Declaration`,
|
||||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||||
filename: `Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||||
},
|
},
|
||||||
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="cert-head-l">
|
<div class="cert-head-l">
|
||||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Form 60 Declaration</h2>
|
<h2 class="cert-head-t">Form 97 Declaration</h2>
|
||||||
<div class="cert-head-s">
|
<div class="cert-head-s">
|
||||||
Income Tax Rule 114B — required for any customer transacting above the threshold who does
|
Income Tax Rule 114B — required for any customer transacting above the threshold who does
|
||||||
<strong>not</strong> have a PAN on file.
|
<strong>not</strong> have a PAN on file.
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ totalFiled() | number }}</div>
|
<div class="cert-kpi-n">{{ totalFiled() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 60 Filed</div>
|
<div class="cert-kpi-l">Form 97 Filed</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="cert-filter">
|
<div class="cert-filter">
|
||||||
<label>PAN status</label>
|
<label>PAN status</label>
|
||||||
<select [(ngModel)]="panFilter">
|
<select [(ngModel)]="panFilter">
|
||||||
<option value="MISSING">Without PAN (Form 60 applicable)</option>
|
<option value="MISSING">Without PAN (Form 97 applicable)</option>
|
||||||
<option value="ON_FILE">With PAN on file</option>
|
<option value="ON_FILE">With PAN on file</option>
|
||||||
<option value="">All customers</option>
|
<option value="">All customers</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<th>Trigger Transaction</th>
|
<th>Trigger Transaction</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
<th>PAN Status</th>
|
<th>PAN Status</th>
|
||||||
<th>Form 60</th>
|
<th>Form 97</th>
|
||||||
<th class="col-actions">Actions</th>
|
<th class="col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
class="cert-icon-btn primary"
|
class="cert-icon-btn primary"
|
||||||
[disabled]="r.hasPan"
|
[disabled]="r.hasPan"
|
||||||
(click)="viewCertificate(r)"
|
(click)="viewCertificate(r)"
|
||||||
matTooltip="View / Print Form 60"
|
matTooltip="View / Print Form 97"
|
||||||
>
|
>
|
||||||
<fa-icon icon="eye"></fa-icon>
|
<fa-icon icon="eye"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class Form60Component implements OnInit {
|
|||||||
|
|
||||||
viewCertificate(r: Form60Record): void {
|
viewCertificate(r: Form60Record): void {
|
||||||
if (r.hasPan) {
|
if (r.hasPan) {
|
||||||
this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
|
this.snack.open(`${r.customerName} already has a PAN on file — Form 97 is not applicable.`, 'OK', {
|
||||||
duration: 3200
|
duration: 3200
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -139,14 +139,14 @@ export class Form60Component implements OnInit {
|
|||||||
|
|
||||||
downloadCertificate(r: Form60Record): void {
|
downloadCertificate(r: Form60Record): void {
|
||||||
if (r.hasPan) {
|
if (r.hasPan) {
|
||||||
this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
|
this.snack.open(`${r.customerName} already has a PAN on file — Form 97 is not applicable.`, 'OK', {
|
||||||
duration: 3200
|
duration: 3200
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.dscSign.promptThenDownload(
|
this.dscSign.promptThenDownload(
|
||||||
{
|
{
|
||||||
title: 'Form 60 Declaration',
|
title: 'Form 97 Declaration',
|
||||||
reference: `${r.customerId} · ${r.customerName}`,
|
reference: `${r.customerId} · ${r.customerName}`,
|
||||||
filename: `Form60-${r.customerId}.pdf`
|
filename: `Form60-${r.customerId}.pdf`
|
||||||
},
|
},
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="rsad-root">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="rsad-state">
|
||||||
|
<fa-icon icon="spinner" class="rsad-state-ic fa-spin"></fa-icon>
|
||||||
|
Loading real share accounts…
|
||||||
|
</div>
|
||||||
|
} @else if (loadError) {
|
||||||
|
<div class="rsad-state rsad-state--error">
|
||||||
|
<fa-icon icon="exclamation-triangle" class="rsad-state-ic"></fa-icon>
|
||||||
|
Could not load share accounts from the server.
|
||||||
|
<button type="button" class="cert-clear" (click)="retry()"><fa-icon icon="redo"></fa-icon> Retry</button>
|
||||||
|
</div>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<div class="rsad-state">
|
||||||
|
<fa-icon icon="info-circle" class="rsad-state-ic"></fa-icon>
|
||||||
|
No share accounts found in m_share_account yet.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- KPI STRIP -->
|
||||||
|
<div class="cert-kpis">
|
||||||
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
|
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Certificates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--teal">
|
||||||
|
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Shares Held</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--green">
|
||||||
|
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
|
||||||
|
<div class="cert-kpi-l">Total Face Value</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FILTERS -->
|
||||||
|
<div class="cert-filters">
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Certificate / Folio / Member / PAN</label>
|
||||||
|
<div class="cert-input-ic">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Share class</label>
|
||||||
|
<select [(ngModel)]="classFilter">
|
||||||
|
<option value="">All classes</option>
|
||||||
|
@for (o of classOptions; track o.value) {
|
||||||
|
<option [value]="o.value">{{ o.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Branch</label>
|
||||||
|
<select [(ngModel)]="branchFilter">
|
||||||
|
<option value="">All branches</option>
|
||||||
|
@for (b of branchOptions; track b) {
|
||||||
|
<option [value]="b">{{ b }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (search || classFilter || branchFilter) {
|
||||||
|
<button type="button" class="cert-clear" (click)="clearFilters()">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TABLE -->
|
||||||
|
<div class="cert-card">
|
||||||
|
<div class="cert-table-wrap">
|
||||||
|
<table class="cert-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member · Folio</th>
|
||||||
|
<th>Certificate No.</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th class="num">Face Value</th>
|
||||||
|
<th class="num">Total</th>
|
||||||
|
<th>Share Nos.</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th class="col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of filteredRows; track row.record.certificateNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="cert-cust-name">{{ row.record.memberName }}</div>
|
||||||
|
<div class="mono small muted">
|
||||||
|
{{ row.record.memberId }} · Folio <span class="cert-folio">{{ row.record.folioNo }}</span> · PAN
|
||||||
|
{{ row.record.memberPan }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.record.certificateNo }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="cert-pill"
|
||||||
|
[class.cert-pill--blue]="row.record.shareClass === 'ORDINARY'"
|
||||||
|
[class.cert-pill--amber]="row.record.shareClass === 'PREFERENCE'"
|
||||||
|
>
|
||||||
|
{{ classLabel(row.record.shareClass) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.record.branch || '—' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong class="cert-shares">{{ row.record.shareCount | number }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ row.record.faceValue | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.record.shareCount * row.record.faceValue | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="mono small">
|
||||||
|
{{ row.record.shareNoFrom | number }}–{{ row.record.shareNoTo | number }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.record.issueDate | date: 'd MMM y' }}</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<div class="cert-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn primary"
|
||||||
|
(click)="viewCertificate(row)"
|
||||||
|
matTooltip="View / Print certificate"
|
||||||
|
>
|
||||||
|
<fa-icon icon="eye"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="downloadCertificate(row)"
|
||||||
|
matTooltip="Download PDF"
|
||||||
|
>
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (filteredRows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="cert-empty">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
No share accounts match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.rsad-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: var(--cert-g500);
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: var(--cert-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsad-state-ic {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
import { forkJoin, of, Observable } from 'rxjs';
|
||||||
|
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { SharesService } from '../../../../shares/shares.service';
|
||||||
|
import { ClientsService } from '../../../../clients/clients.service';
|
||||||
|
import { ShareCertificateDialogComponent } from '../../../../shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component';
|
||||||
|
import { downloadShareCertificatePdf } from '../../certificate-pdf';
|
||||||
|
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
|
||||||
|
import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component';
|
||||||
|
|
||||||
|
/** A real share account, kept alongside the raw API payload needed to open the real certificate dialog. */
|
||||||
|
export interface RealShareAccountRow {
|
||||||
|
record: ShareCertificateRecord;
|
||||||
|
sharesAccountData: any;
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-real-share-accounts-view',
|
||||||
|
templateUrl: './real-share-accounts-view.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../certificates.shared.scss',
|
||||||
|
'./real-share-accounts-view.component.scss'
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent,
|
||||||
|
MatTooltip
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class RealShareAccountsViewComponent implements OnInit {
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private dscSign = inject(DscSignService);
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
loadError = false;
|
||||||
|
rows: RealShareAccountRow[] = [];
|
||||||
|
|
||||||
|
// ── Filters (mirrors the demo list above) ──
|
||||||
|
search = '';
|
||||||
|
classFilter: '' | ShareClass = '';
|
||||||
|
branchFilter = '';
|
||||||
|
|
||||||
|
readonly classOptions: { value: ShareClass; label: string }[] = [
|
||||||
|
{ value: 'ORDINARY', label: 'Ordinary' },
|
||||||
|
{ value: 'PREFERENCE', label: 'Preference' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Per-client caches so accounts sharing the same client (a common case — one
|
||||||
|
// member can hold several share accounts) only fetch that client once.
|
||||||
|
private clientDataCache = new Map<string, Observable<any>>();
|
||||||
|
private clientIdentifiersCache = new Map<string, Observable<any>>();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadRealAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRealAccounts(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadError = false;
|
||||||
|
this.clientDataCache.clear();
|
||||||
|
this.clientIdentifiersCache.clear();
|
||||||
|
this.sharesService
|
||||||
|
.getAllShareAccounts(0, 200)
|
||||||
|
.pipe(
|
||||||
|
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
|
||||||
|
switchMap((items: any[]) => {
|
||||||
|
if (!items.length) {
|
||||||
|
return of([] as RealShareAccountRow[]);
|
||||||
|
}
|
||||||
|
return forkJoin(items.map((item) => this.buildRow(item))).pipe(
|
||||||
|
map((rows) => rows.filter((r): r is RealShareAccountRow => !!r))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.loadError = true;
|
||||||
|
return of([] as RealShareAccountRow[]);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.rows = rows;
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share-account list already carries clientId/clientName (same mapper as the
|
||||||
|
* single-account read), so the account detail call and the client calls can fire
|
||||||
|
* together instead of waiting on each other — halving the round trips on the
|
||||||
|
* critical path.
|
||||||
|
*/
|
||||||
|
private buildRow(listItem: any): Observable<RealShareAccountRow | null> {
|
||||||
|
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
|
||||||
|
return forkJoin({
|
||||||
|
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
|
||||||
|
client: clientId ? this.getClientDataCached(clientId) : of(null),
|
||||||
|
identifiers: clientId ? this.getClientIdentifiersCached(clientId) : of([])
|
||||||
|
}).pipe(
|
||||||
|
switchMap(({ sa, client, identifiers }) => {
|
||||||
|
// Defensive fallback if the list payload didn't carry clientId for some reason.
|
||||||
|
if (clientId || !sa?.clientId) {
|
||||||
|
return of({ sa, client, identifiers });
|
||||||
|
}
|
||||||
|
const fallbackClientId = String(sa.clientId);
|
||||||
|
return forkJoin({
|
||||||
|
sa: of(sa),
|
||||||
|
client: this.getClientDataCached(fallbackClientId),
|
||||||
|
identifiers: this.getClientIdentifiersCached(fallbackClientId)
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
map(({ sa, client, identifiers }: any) => ({
|
||||||
|
record: this.mapToRecord(sa, client, identifiers),
|
||||||
|
sharesAccountData: sa,
|
||||||
|
clientId: String(sa.clientId)
|
||||||
|
})),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientDataCached(clientId: string): Observable<any> {
|
||||||
|
if (!this.clientDataCache.has(clientId)) {
|
||||||
|
this.clientDataCache.set(
|
||||||
|
clientId,
|
||||||
|
this.clientsService.getClientData(clientId).pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
shareReplay(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.clientDataCache.get(clientId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientIdentifiersCached(clientId: string): Observable<any> {
|
||||||
|
if (!this.clientIdentifiersCache.has(clientId)) {
|
||||||
|
this.clientIdentifiersCache.set(
|
||||||
|
clientId,
|
||||||
|
this.clientsService.getClientIdentifiers(clientId).pipe(
|
||||||
|
catchError(() => of([])),
|
||||||
|
shareReplay(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.clientIdentifiersCache.get(clientId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToRecord(sa: any, client: any, identifiers: any): ShareCertificateRecord {
|
||||||
|
const approvedShares = sa.summary?.totalApprovedShares || 0;
|
||||||
|
const faceValue = sa.currentMarketPrice || 0;
|
||||||
|
const issueDate = this.toIsoDate(sa.timeline?.activatedDate) || this.toIsoDate(sa.timeline?.approvedDate) || '';
|
||||||
|
const joiningDate =
|
||||||
|
this.toIsoDate(client?.activationDate) || this.toIsoDate(client?.timeline?.submittedOnDate) || issueDate;
|
||||||
|
|
||||||
|
const startNum = sa.id * 5000 + 201;
|
||||||
|
const endNum = startNum + approvedShares - 1;
|
||||||
|
|
||||||
|
const idList = Array.isArray(identifiers) ? identifiers : identifiers?.pageItems || [];
|
||||||
|
const panId = idList.find((i: any) => /pan/i.test(i?.documentType?.name || ''));
|
||||||
|
const memberPan = panId ? panId.documentKey : '—';
|
||||||
|
|
||||||
|
const dividendYtd = (sa.dividends || []).reduce((sum: number, d: any) => sum + (d.amount || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificateNo: `SC-2026-${sa.accountNo || String(sa.id).padStart(6, '0')}`,
|
||||||
|
folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId).padStart(5, '0')}`,
|
||||||
|
memberId: client?.accountNo || '',
|
||||||
|
memberName: sa.clientName || client?.displayName || '',
|
||||||
|
memberPan,
|
||||||
|
// Address isn't shown in this table — fetched on demand when a PDF is downloaded.
|
||||||
|
memberAddress: '',
|
||||||
|
branch: client?.officeName || '',
|
||||||
|
joiningDate,
|
||||||
|
shareClass: /prefer/i.test(sa.productName || '') ? 'PREFERENCE' : 'ORDINARY',
|
||||||
|
shareCount: approvedShares,
|
||||||
|
faceValue,
|
||||||
|
issueDate,
|
||||||
|
shareNoFrom: startNum,
|
||||||
|
shareNoTo: approvedShares > 0 ? endNum : startNum,
|
||||||
|
dividendYtd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toIsoDate(value: any): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m,
|
||||||
|
d
|
||||||
|
] = value;
|
||||||
|
if (!y) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAddress(addr: any): string {
|
||||||
|
if (!addr) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
addr.addressLine1,
|
||||||
|
addr.addressLine2,
|
||||||
|
addr.townVillage,
|
||||||
|
addr.city,
|
||||||
|
addr.stateName,
|
||||||
|
addr.countryName,
|
||||||
|
addr.postalCode
|
||||||
|
]
|
||||||
|
.filter((p) => !!p && String(p).trim())
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived list (identical shape to the demo table above) ──
|
||||||
|
get filteredRows(): RealShareAccountRow[] {
|
||||||
|
const q = this.search.trim().toLowerCase();
|
||||||
|
const branch = this.branchFilter.trim().toLowerCase();
|
||||||
|
return this.rows.filter(({ record: r }) => {
|
||||||
|
if (this.classFilter && r.shareClass !== this.classFilter) return false;
|
||||||
|
if (branch && !r.branch.toLowerCase().includes(branch)) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
r.certificateNo.toLowerCase().includes(q) ||
|
||||||
|
r.folioNo.toLowerCase().includes(q) ||
|
||||||
|
r.memberId.toLowerCase().includes(q) ||
|
||||||
|
r.memberName.toLowerCase().includes(q) ||
|
||||||
|
r.memberPan.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get branchOptions(): string[] {
|
||||||
|
return Array.from(new Set(this.rows.map((row) => row.record.branch).filter(Boolean))).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
classLabel(c: ShareClass): string {
|
||||||
|
return this.classOptions.find((o) => o.value === c)?.label || c;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalShares(): number {
|
||||||
|
return this.filteredRows.reduce((s, { record: r }) => s + r.shareCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalShareValue(): number {
|
||||||
|
return this.filteredRows.reduce((s, { record: r }) => s + r.shareCount * r.faceValue, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.classFilter = '';
|
||||||
|
this.branchFilter = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(): void {
|
||||||
|
this.loadRealAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the same certificate dialog used from Client → Share Account → Share Certificate. */
|
||||||
|
viewCertificate(row: RealShareAccountRow): void {
|
||||||
|
this.dialog.open(ShareCertificateDialogComponent, {
|
||||||
|
data: {
|
||||||
|
sharesAccountData: row.sharesAccountData,
|
||||||
|
clientId: row.clientId
|
||||||
|
},
|
||||||
|
width: '850px',
|
||||||
|
maxWidth: '95vw'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloads the certificate as a PDF. Address is fetched here (only place it's needed). */
|
||||||
|
downloadCertificate(row: RealShareAccountRow): void {
|
||||||
|
this.clientsService
|
||||||
|
.getClientAddressData(row.clientId)
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((addresses: any) => {
|
||||||
|
const addrList = Array.isArray(addresses) ? addresses : [];
|
||||||
|
const permanent = addrList.find((a: any) => /permanent/i.test(a?.addressType || '')) || addrList[0];
|
||||||
|
const r: ShareCertificateRecord = {
|
||||||
|
...row.record,
|
||||||
|
memberAddress: permanent ? this.formatAddress(permanent) : row.record.memberAddress
|
||||||
|
};
|
||||||
|
this.dscSign.promptThenDownload(
|
||||||
|
{
|
||||||
|
title: 'Share Certificate',
|
||||||
|
reference: `${r.certificateNo} · Folio ${r.folioNo} · ${r.memberName}`,
|
||||||
|
filename: `Share-Certificate-${r.certificateNo}.pdf`
|
||||||
|
},
|
||||||
|
(dsc) => downloadShareCertificatePdf(r, dsc)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
-132
@@ -14,151 +14,184 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Share Certificate</h2>
|
<h2 class="cert-head-t">Share Certificate</h2>
|
||||||
<div class="cert-head-s">
|
<div class="cert-head-s">
|
||||||
Issue, preview and download member share certificates with folio + share-number range.
|
@if (viewMode === 'demo') {
|
||||||
|
Issue, preview and download member share certificates with folio + share-number range.
|
||||||
|
} @else {
|
||||||
|
Live holder details and certificates, sourced from m_share_account.
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cert-head-r">
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Same fields and certificate, using real m_share_account data"
|
||||||
|
>
|
||||||
|
<fa-icon icon="database"></fa-icon> Real Share Accounts
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn cert-cta-btn--muted"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Back to the sample data page"
|
||||||
|
>
|
||||||
|
<fa-icon icon="table"></fa-icon> Sample Data
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (viewMode === 'real') {
|
||||||
|
<mifosx-real-share-accounts-view></mifosx-real-share-accounts-view>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- KPI STRIP -->
|
<!-- KPI STRIP -->
|
||||||
<div class="cert-kpis">
|
@if (viewMode === 'demo') {
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
<div class="cert-kpis">
|
||||||
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
<div>
|
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
|
||||||
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
|
<div>
|
||||||
<div class="cert-kpi-l">Certificates</div>
|
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Certificates</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="cert-kpi cert-kpi--teal">
|
||||||
<div class="cert-kpi cert-kpi--teal">
|
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
|
||||||
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
|
<div>
|
||||||
<div>
|
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
|
||||||
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
|
<div class="cert-kpi-l">Shares Held</div>
|
||||||
<div class="cert-kpi-l">Shares Held</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="cert-kpi cert-kpi--green">
|
||||||
<div class="cert-kpi cert-kpi--green">
|
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
<div>
|
||||||
<div>
|
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
|
||||||
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
|
<div class="cert-kpi-l">Total Face Value</div>
|
||||||
<div class="cert-kpi-l">Total Face Value</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FILTERS -->
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Certificate / Folio / Member / PAN</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<!-- FILTERS -->
|
||||||
<label>Share class</label>
|
<div class="cert-filters">
|
||||||
<select [(ngModel)]="classFilter">
|
<div class="cert-filter">
|
||||||
<option value="">All classes</option>
|
<label>Certificate / Folio / Member / PAN</label>
|
||||||
@for (o of classOptions; track o.value) {
|
<div class="cert-input-ic">
|
||||||
<option [value]="o.value">{{ o.label }}</option>
|
<fa-icon icon="search"></fa-icon>
|
||||||
}
|
<input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<div class="cert-filter">
|
||||||
<label>Branch</label>
|
<label>Share class</label>
|
||||||
<select [(ngModel)]="branchFilter">
|
<select [(ngModel)]="classFilter">
|
||||||
<option value="">All branches</option>
|
<option value="">All classes</option>
|
||||||
@for (b of branchOptions; track b) {
|
@for (o of classOptions; track o.value) {
|
||||||
<option [value]="b">{{ b }}</option>
|
<option [value]="o.value">{{ o.label }}</option>
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (search || classFilter || branchFilter) {
|
|
||||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TABLE -->
|
|
||||||
<div class="cert-card">
|
|
||||||
<div class="cert-table-wrap">
|
|
||||||
<table class="cert-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member · Folio</th>
|
|
||||||
<th>Certificate No.</th>
|
|
||||||
<th>Class</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Shares</th>
|
|
||||||
<th class="num">Face Value</th>
|
|
||||||
<th class="num">Total</th>
|
|
||||||
<th>Share Nos.</th>
|
|
||||||
<th>Issued</th>
|
|
||||||
<th class="col-actions">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of filteredRecords; track r.certificateNo) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="cert-cust-name">{{ r.memberName }}</div>
|
|
||||||
<div class="mono small muted">
|
|
||||||
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="mono">{{ r.certificateNo }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cert-pill"
|
|
||||||
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
|
|
||||||
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
|
|
||||||
>
|
|
||||||
{{ classLabel(r.shareClass) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="small">{{ r.branch }}</td>
|
|
||||||
<td class="num">
|
|
||||||
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">
|
|
||||||
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="mono small">{{ r.shareNoFrom }}–{{ r.shareNoTo }}</td>
|
|
||||||
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
|
|
||||||
<td class="col-actions">
|
|
||||||
<div class="cert-row-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cert-icon-btn primary"
|
|
||||||
(click)="viewCertificate(r)"
|
|
||||||
matTooltip="View / Print certificate"
|
|
||||||
>
|
|
||||||
<fa-icon icon="eye"></fa-icon>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cert-icon-btn"
|
|
||||||
(click)="downloadCertificate(r)"
|
|
||||||
matTooltip="Download PDF"
|
|
||||||
>
|
|
||||||
<fa-icon icon="download"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
@if (filteredRecords.length === 0) {
|
</select>
|
||||||
<tr>
|
</div>
|
||||||
<td colspan="10" class="cert-empty">
|
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
<div class="cert-filter">
|
||||||
No share certificates match the current filters.
|
<label>Branch</label>
|
||||||
</td>
|
<select [(ngModel)]="branchFilter">
|
||||||
</tr>
|
<option value="">All branches</option>
|
||||||
|
@for (b of branchOptions; track b) {
|
||||||
|
<option [value]="b">{{ b }}</option>
|
||||||
}
|
}
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
</div>
|
||||||
|
|
||||||
|
@if (search || classFilter || branchFilter) {
|
||||||
|
<button type="button" class="cert-clear" (click)="clearFilters()">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- TABLE -->
|
||||||
|
<div class="cert-card">
|
||||||
|
<div class="cert-table-wrap">
|
||||||
|
<table class="cert-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member · Folio</th>
|
||||||
|
<th>Certificate No.</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th class="num">Face Value</th>
|
||||||
|
<th class="num">Total</th>
|
||||||
|
<th>Share Nos.</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th class="col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of filteredRecords; track r.certificateNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="cert-cust-name">{{ r.memberName }}</div>
|
||||||
|
<div class="mono small muted">
|
||||||
|
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ r.certificateNo }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="cert-pill"
|
||||||
|
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
|
||||||
|
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
|
||||||
|
>
|
||||||
|
{{ classLabel(r.shareClass) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ r.branch }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="mono small">{{ r.shareNoFrom }}–{{ r.shareNoTo }}</td>
|
||||||
|
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<div class="cert-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn primary"
|
||||||
|
(click)="viewCertificate(r)"
|
||||||
|
matTooltip="View / Print certificate"
|
||||||
|
>
|
||||||
|
<fa-icon icon="eye"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="downloadCertificate(r)"
|
||||||
|
matTooltip="Download PDF"
|
||||||
|
>
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (filteredRecords.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="cert-empty">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
No share certificates match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||||
import { downloadShareCertificatePdf } from '../certificate-pdf';
|
import { downloadShareCertificatePdf } from '../certificate-pdf';
|
||||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||||
|
import { RealShareAccountsViewComponent } from './real-share-accounts-view/real-share-accounts-view.component';
|
||||||
|
|
||||||
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
|
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ export interface ShareCertificateRecord {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatTooltip
|
MatTooltip,
|
||||||
|
RealShareAccountsViewComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ShareCertificateComponent implements OnInit {
|
export class ShareCertificateComponent implements OnInit {
|
||||||
@@ -61,6 +63,9 @@ export class ShareCertificateComponent implements OnInit {
|
|||||||
private snack = inject(MatSnackBar);
|
private snack = inject(MatSnackBar);
|
||||||
private dscSign = inject(DscSignService);
|
private dscSign = inject(DscSignService);
|
||||||
|
|
||||||
|
/** Toggles between the static sample table and the live m_share_account view. */
|
||||||
|
viewMode: 'demo' | 'real' = 'demo';
|
||||||
|
|
||||||
// ── Filters ──
|
// ── Filters ──
|
||||||
search = '';
|
search = '';
|
||||||
classFilter: '' | ShareClass = '';
|
classFilter: '' | ShareClass = '';
|
||||||
@@ -143,6 +148,11 @@ export class ShareCertificateComponent implements OnInit {
|
|||||||
(dsc) => downloadShareCertificatePdf(r, dsc)
|
(dsc) => downloadShareCertificatePdf(r, dsc)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Switches between the static sample table and the live m_share_account view. */
|
||||||
|
toggleViewMode(): void {
|
||||||
|
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────── Mock seed (top-level export) ─────────────────────
|
// ───────────────────── Mock seed (top-level export) ─────────────────────
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'letter-mgmt/form-60',
|
path: 'letter-mgmt/form-60',
|
||||||
component: Form60Component,
|
component: Form60Component,
|
||||||
data: { title: 'Form 60', breadcrumb: 'Form 60' }
|
data: { title: 'Form 97', breadcrumb: 'Form 97' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'letter-mgmt/form-15gh',
|
path: 'letter-mgmt/form-15gh',
|
||||||
component: Form15ghComponent,
|
component: Form15ghComponent,
|
||||||
data: { title: 'Form 15G & 15H', breadcrumb: 'Form 15G/H' }
|
data: { title: 'Form 121', breadcrumb: 'Form 121' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'letter-mgmt/hypothecation-certificate',
|
path: 'letter-mgmt/hypothecation-certificate',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="aga-card">
|
||||||
|
<div class="aga-icon">
|
||||||
|
<fa-icon icon="file-signature"></fa-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="aga-title">Terms & Agreement</h2>
|
||||||
|
<p class="aga-sub">
|
||||||
|
@if (loading) {
|
||||||
|
Loading the agreement…
|
||||||
|
} @else if (loadErrorMessage) {
|
||||||
|
We couldn't load the agreement.
|
||||||
|
} @else {
|
||||||
|
Please read the agreement below before continuing.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="aga-loading">
|
||||||
|
<mat-spinner diameter="32"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
} @else if (loadErrorMessage) {
|
||||||
|
<div class="aga-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
{{ loadErrorMessage }}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="aga-btn aga-btn--ghost" (click)="loadAgreement()">
|
||||||
|
<fa-icon icon="sync-alt"></fa-icon>
|
||||||
|
<span>Try Again</span>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<div class="aga-agreement">
|
||||||
|
<h3 class="aga-agreement__title">{{ agreementTitle }}</h3>
|
||||||
|
<div class="aga-agreement__body">{{ agreementBody }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="aga-check">
|
||||||
|
<input type="checkbox" [checked]="accepted" (change)="toggleAccepted()" />
|
||||||
|
<span>I have read and agree to the above</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (submitErrorMessage) {
|
||||||
|
<div class="aga-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
{{ submitErrorMessage }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="aga-btn aga-btn--primary"
|
||||||
|
[disabled]="!accepted || submitting"
|
||||||
|
(click)="acceptAndContinue()"
|
||||||
|
>
|
||||||
|
@if (submitting) {
|
||||||
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
<span>Submitting…</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>Accept & Continue</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
animation: aga-fade 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 7%) 0%, rgb(255 255 255 / 4%) 100%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 14%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
box-shadow:
|
||||||
|
0 30px 60px rgb(0 0 0 / 35%),
|
||||||
|
0 10px 24px rgb(0 0 0 / 22%),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 8%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-icon {
|
||||||
|
align-self: center;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 26px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 28px rgb(37 99 235 / 45%),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 10%),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 25%);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-sub {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(255 255 255 / 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-agreement {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgb(255 255 255 / 5%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgb(255 255 255 / 78%);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(255 255 255 / 80%);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
// stylelint-disable-next-line selector-no-qualifying-type
|
||||||
|
input[type='checkbox'] {
|
||||||
|
margin-top: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(239 68 68 / 14%);
|
||||||
|
border: 1px solid rgb(239 68 68 / 35%);
|
||||||
|
color: #fecaca;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aga-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s,
|
||||||
|
box-shadow 0.18s,
|
||||||
|
transform 0.18s,
|
||||||
|
opacity 0.18s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 60%, #1e3a8a 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 24px rgb(37 99 235 / 38%),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 6%),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 22%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 25%));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: linear-gradient(135deg, #4f8ff7 0%, #2563eb 60%, #1e3a8a 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 28px rgb(37 99 235 / 48%),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 8%),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 26%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(255 255 255 / 80%);
|
||||||
|
border-color: rgb(255 255 255 / 18%);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgb(255 255 255 / 30%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-spinner ::ng-deep circle {
|
||||||
|
stroke: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aga-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 480px) {
|
||||||
|
.aga-card {
|
||||||
|
padding: 18px 16px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject } from '@angular/core';
|
||||||
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
/** rxjs Imports */
|
||||||
|
import { finalize } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown right after a successful login (OTP included, if the account has it enrolled) when the
|
||||||
|
* signed-in user hasn't yet accepted the bank's current agreement/disclaimer. Non-dismissable —
|
||||||
|
* mirrors TwoFactorAuthenticationComponent's dialog posture.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-agreement-accept',
|
||||||
|
templateUrl: './agreement-accept.component.html',
|
||||||
|
styleUrls: ['./agreement-accept.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatProgressSpinner,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AgreementAcceptComponent implements OnInit {
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
public dialogRef = inject(MatDialogRef<AgreementAcceptComponent>);
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
loadErrorMessage = '';
|
||||||
|
|
||||||
|
agreementTitle = '';
|
||||||
|
agreementBody = '';
|
||||||
|
accepted = false;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
submitErrorMessage = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAgreement();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAgreement(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadErrorMessage = '';
|
||||||
|
this.authenticationService
|
||||||
|
.getAgreementStatus()
|
||||||
|
.pipe(finalize(() => (this.loading = false)))
|
||||||
|
.subscribe({
|
||||||
|
next: (status) => {
|
||||||
|
this.agreementTitle = status?.title || '';
|
||||||
|
this.agreementBody = status?.body || '';
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.loadErrorMessage =
|
||||||
|
err?.error?.defaultUserMessage || err?.error?.message || 'Could not load the agreement.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAccepted(): void {
|
||||||
|
this.accepted = !this.accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptAndContinue(): void {
|
||||||
|
if (!this.accepted || this.submitting) return;
|
||||||
|
this.submitErrorMessage = '';
|
||||||
|
this.submitting = true;
|
||||||
|
this.authenticationService
|
||||||
|
.acceptActiveAgreement()
|
||||||
|
.pipe(finalize(() => (this.submitting = false)))
|
||||||
|
.subscribe({
|
||||||
|
error: (err) => {
|
||||||
|
this.submitErrorMessage =
|
||||||
|
err?.error?.defaultUserMessage || err?.error?.message || 'Could not record your acceptance.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,13 +247,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Forms -->
|
<!-- Login Forms -->
|
||||||
@if (!resetPassword && !twoFactorAuthenticationRequired) {
|
@if (!resetPassword && !twoFactorAuthenticationRequired && !agreementAcceptanceRequired) {
|
||||||
<mifosx-login-form></mifosx-login-form>
|
<mifosx-login-form></mifosx-login-form>
|
||||||
}
|
}
|
||||||
@if (resetPassword) {
|
@if (resetPassword) {
|
||||||
<mifosx-reset-password></mifosx-reset-password>
|
<mifosx-reset-password></mifosx-reset-password>
|
||||||
}
|
}
|
||||||
<!-- 2FA opens as a non-dismissable dialog from login.component.ts -->
|
<!-- 2FA and the post-login agreement dialog open as non-dismissable dialogs from login.component.ts -->
|
||||||
|
|
||||||
<div class="right-footer">RBI Licensed · PCI-DSS Compliant · ISO 27001 Certified</div>
|
<div class="right-footer">RBI Licensed · PCI-DSS Compliant · ISO 27001 Certified</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { TenantSelectorComponent } from '../shared/tenant-selector/tenant-select
|
|||||||
import { LoginFormComponent } from './login-form/login-form.component';
|
import { LoginFormComponent } from './login-form/login-form.component';
|
||||||
import { ResetPasswordComponent } from './reset-password/reset-password.component';
|
import { ResetPasswordComponent } from './reset-password/reset-password.component';
|
||||||
import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component';
|
import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component';
|
||||||
|
import { AgreementAcceptComponent } from './agreement-accept/agreement-accept.component';
|
||||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
|
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
@@ -85,6 +86,7 @@ import { AuthenticationService } from '../core/authentication/authentication.ser
|
|||||||
LoginFormComponent,
|
LoginFormComponent,
|
||||||
ResetPasswordComponent,
|
ResetPasswordComponent,
|
||||||
TwoFactorAuthenticationComponent,
|
TwoFactorAuthenticationComponent,
|
||||||
|
AgreementAcceptComponent,
|
||||||
MatMenuTrigger,
|
MatMenuTrigger,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatMenu,
|
MatMenu,
|
||||||
@@ -220,6 +222,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
private themingService = inject(ThemingService);
|
private themingService = inject(ThemingService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private twoFactorDialogRef: MatDialogRef<TwoFactorAuthenticationComponent> | null = null;
|
private twoFactorDialogRef: MatDialogRef<TwoFactorAuthenticationComponent> | null = null;
|
||||||
|
private agreementAcceptDialogRef: MatDialogRef<AgreementAcceptComponent> | null = null;
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private authService = inject(AuthenticationService);
|
private authService = inject(AuthenticationService);
|
||||||
|
|
||||||
@@ -245,6 +248,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
resetPassword = false;
|
resetPassword = false;
|
||||||
/** True if user requires two factor authentication. */
|
/** True if user requires two factor authentication. */
|
||||||
twoFactorAuthenticationRequired = false;
|
twoFactorAuthenticationRequired = false;
|
||||||
|
/** True if the signed-in user still needs to accept the current agreement/disclaimer. */
|
||||||
|
agreementAcceptanceRequired = false;
|
||||||
/** Subscription to alerts. */
|
/** Subscription to alerts. */
|
||||||
alert$: Subscription;
|
alert$: Subscription;
|
||||||
logoPath = 'assets/images/cbsnexora-logo.png';
|
logoPath = 'assets/images/cbsnexora-logo.png';
|
||||||
@@ -275,16 +280,24 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
const alertType = alertEvent.type;
|
const alertType = alertEvent.type;
|
||||||
if (alertType === 'Password Expired') {
|
if (alertType === 'Password Expired') {
|
||||||
this.twoFactorAuthenticationRequired = false;
|
this.twoFactorAuthenticationRequired = false;
|
||||||
|
this.agreementAcceptanceRequired = false;
|
||||||
this.closeTwoFactorDialog();
|
this.closeTwoFactorDialog();
|
||||||
|
this.closeAgreementAcceptDialog();
|
||||||
this.resetPassword = true;
|
this.resetPassword = true;
|
||||||
} else if (alertType === 'Two Factor Authentication Required') {
|
} else if (alertType === 'Two Factor Authentication Required') {
|
||||||
this.resetPassword = false;
|
this.resetPassword = false;
|
||||||
this.twoFactorAuthenticationRequired = true;
|
this.twoFactorAuthenticationRequired = true;
|
||||||
this.openTwoFactorDialog();
|
this.openTwoFactorDialog();
|
||||||
|
} else if (alertType === 'Agreement Acceptance Required') {
|
||||||
|
this.resetPassword = false;
|
||||||
|
this.agreementAcceptanceRequired = true;
|
||||||
|
this.openAgreementAcceptDialog();
|
||||||
} else if (alertType === 'Authentication Success') {
|
} else if (alertType === 'Authentication Success') {
|
||||||
this.resetPassword = false;
|
this.resetPassword = false;
|
||||||
this.twoFactorAuthenticationRequired = false;
|
this.twoFactorAuthenticationRequired = false;
|
||||||
|
this.agreementAcceptanceRequired = false;
|
||||||
this.closeTwoFactorDialog();
|
this.closeTwoFactorDialog();
|
||||||
|
this.closeAgreementAcceptDialog();
|
||||||
// Cashier users go directly to the full-screen transaction entry dashboard
|
// Cashier users go directly to the full-screen transaction entry dashboard
|
||||||
const destination = this.authService.isCashierUser() ? ['/cashier/transaction-entry'] : ['/'];
|
const destination = this.authService.isCashierUser() ? ['/cashier/transaction-entry'] : ['/'];
|
||||||
this.router.navigate(destination, { replaceUrl: true });
|
this.router.navigate(destination, { replaceUrl: true });
|
||||||
@@ -334,6 +347,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
this.theme$.unsubscribe();
|
this.theme$.unsubscribe();
|
||||||
}
|
}
|
||||||
this.closeTwoFactorDialog();
|
this.closeTwoFactorDialog();
|
||||||
|
this.closeAgreementAcceptDialog();
|
||||||
clearInterval(this.slideTimer);
|
clearInterval(this.slideTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +375,30 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the post-login agreement acceptance dialog as a non-dismissable modal */
|
||||||
|
private openAgreementAcceptDialog(): void {
|
||||||
|
if (this.agreementAcceptDialogRef) return;
|
||||||
|
this.agreementAcceptDialogRef = this.dialog.open(AgreementAcceptComponent, {
|
||||||
|
width: '460px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
panelClass: 'tfa-dialog-panel',
|
||||||
|
backdropClass: 'tfa-dialog-backdrop',
|
||||||
|
disableClose: true,
|
||||||
|
autoFocus: 'first-tabbable',
|
||||||
|
restoreFocus: true
|
||||||
|
});
|
||||||
|
this.agreementAcceptDialogRef.afterClosed().subscribe(() => {
|
||||||
|
this.agreementAcceptDialogRef = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeAgreementAcceptDialog(): void {
|
||||||
|
if (this.agreementAcceptDialogRef) {
|
||||||
|
this.agreementAcceptDialogRef.close();
|
||||||
|
this.agreementAcceptDialogRef = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
goToSlide(index: number): void {
|
goToSlide(index: number): void {
|
||||||
this.currentSlide = index;
|
this.currentSlide = index;
|
||||||
this.restartSlider();
|
this.restartSlider();
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="container aml-rules-container">
|
||||||
|
<mat-card class="aml-card">
|
||||||
|
<mat-card-header class="aml-card-header">
|
||||||
|
<div class="aml-header-content">
|
||||||
|
<mat-icon class="header-icon">shield</mat-icon>
|
||||||
|
<div>
|
||||||
|
<mat-card-title class="header-title">AML Rules Master</mat-card-title>
|
||||||
|
<mat-card-subtitle class="header-subtitle">
|
||||||
|
Database-driven rules for client risk categorization and AML compliance checks
|
||||||
|
</mat-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content class="aml-card-content">
|
||||||
|
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||||
|
<!-- Order Position Column -->
|
||||||
|
<ng-container matColumnDef="orderPosition">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Rule Name Column -->
|
||||||
|
<ng-container matColumnDef="ruleName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Risk Category Column -->
|
||||||
|
<ng-container matColumnDef="riskCategory">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-center">
|
||||||
|
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
|
||||||
|
<span class="dot"></span>
|
||||||
|
{{ getRiskCategoryLabel(element.riskCategory) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Reason Column -->
|
||||||
|
<ng-container matColumnDef="reason">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- AML Check Status Column -->
|
||||||
|
<ng-container matColumnDef="amlCheckStatus">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-center">
|
||||||
|
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
|
||||||
|
{{ element.amlCheckStatus || 'CLEAR' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Banned Entity Match Column -->
|
||||||
|
<ng-container matColumnDef="bannedEntityMatch">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-center">
|
||||||
|
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
|
||||||
|
{{ element.bannedEntityMatch || 'NO' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Mat Paginator for standard pagination -->
|
||||||
|
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
|
||||||
|
|
||||||
|
<div *ngIf="rules.data.length === 0" class="no-rules-message">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<span>No AML rules found in the database.</span>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.aml-rules-container {
|
||||||
|
padding: 24px;
|
||||||
|
background-color: var(--bg-neutral-light, #f8fafc);
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 20px -2px rgb(0 0 0 / 5%),
|
||||||
|
0 2px 8px -1px rgb(0 0 0 / 3%);
|
||||||
|
border: 1px solid rgb(226 232 240 / 80%);
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-card-header {
|
||||||
|
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||||
|
color: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-card-content {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #334155;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-center {
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-name-cell {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-cell {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges for Risk Category */
|
||||||
|
.risk-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-high {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid #fee2e2;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background-color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-medium {
|
||||||
|
background-color: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border: 1px solid #fef3c7;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background-color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-low {
|
||||||
|
background-color: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
border: 1px solid #dcfce7;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
background-color: #16a34a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges for Compliance status */
|
||||||
|
.compliance-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
|
||||||
|
/* AML Statuses */
|
||||||
|
&.status-clear {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-flagged {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-blocked {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banned Matches */
|
||||||
|
&.match-no {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.match-yes {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.match-partial_match {
|
||||||
|
background-color: #ffedd5;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-rules-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #64748b;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aml-paginator {
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject, ViewChild } from '@angular/core';
|
||||||
|
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ClientsService } from '../../clients/clients.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-aml-rules',
|
||||||
|
templateUrl: './aml-rules.component.html',
|
||||||
|
styleUrls: ['./aml-rules.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatPaginatorModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AmlRulesComponent implements OnInit {
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
rules = new MatTableDataSource<any>([]);
|
||||||
|
displayedColumns: string[] = [
|
||||||
|
'orderPosition',
|
||||||
|
'ruleName',
|
||||||
|
'riskCategory',
|
||||||
|
'reason',
|
||||||
|
'amlCheckStatus',
|
||||||
|
'bannedEntityMatch'
|
||||||
|
];
|
||||||
|
|
||||||
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.clientsService.getClientRiskRules().subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.rules.data = data || [];
|
||||||
|
this.rules.paginator = this.paginator;
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getRiskCategoryLabel(category: number): string {
|
||||||
|
switch (category) {
|
||||||
|
case 1:
|
||||||
|
return 'Low Risk';
|
||||||
|
case 2:
|
||||||
|
return 'Medium Risk';
|
||||||
|
case 3:
|
||||||
|
return 'High Risk';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRiskColorClass(category: number): string {
|
||||||
|
switch (category) {
|
||||||
|
case 1:
|
||||||
|
return 'risk-low';
|
||||||
|
case 2:
|
||||||
|
return 'risk-medium';
|
||||||
|
case 3:
|
||||||
|
return 'risk-high';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
|
|||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
import { Route } from '../core/route/route.service';
|
||||||
import { RbiCircularsComponent } from './rbi-circulars.component';
|
import { RbiCircularsComponent } from './rbi-circulars.component';
|
||||||
|
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -18,6 +19,11 @@ const routes: Routes = [
|
|||||||
path: 'rbi-circulars',
|
path: 'rbi-circulars',
|
||||||
component: RbiCircularsComponent,
|
component: RbiCircularsComponent,
|
||||||
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
|
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'rbi/aml',
|
||||||
|
component: AmlRulesComponent,
|
||||||
|
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="sav-view">
|
<div class="sav-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="savingsAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="sav-hero">
|
<header class="sav-hero">
|
||||||
<div class="sav-hero__left">
|
<div class="sav-hero__left">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { AccountsService } from 'app/accounts/accounts.service';
|
import { AccountsService } from 'app/accounts/accounts.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
|
||||||
/** Environment Configuration */
|
/** Environment Configuration */
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
@@ -41,6 +42,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Savings Account View Component
|
* Savings Account View Component
|
||||||
@@ -70,7 +72,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatTabNavPanel,
|
MatTabNavPanel,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class SavingsAccountViewComponent implements OnInit {
|
export class SavingsAccountViewComponent implements OnInit {
|
||||||
@@ -81,6 +84,7 @@ export class SavingsAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** Savings Account Data */
|
/** Savings Account Data */
|
||||||
@@ -268,6 +272,14 @@ export class SavingsAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -195,6 +195,9 @@
|
|||||||
@if (statusId >= 100) {
|
@if (statusId >= 100) {
|
||||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||||
<span class="dap__card-hint">Account is inactive</span>
|
<span class="dap__card-hint">Account is inactive</span>
|
||||||
|
} @else if (isOverdue(daysToInactive)) {
|
||||||
|
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToInactive! }}d</span>
|
||||||
|
<span class="dap__card-hint">{{ inactiveSubtext() }}</span>
|
||||||
} @else if (daysToInactive !== null) {
|
} @else if (daysToInactive !== null) {
|
||||||
<span class="dap__number" [class.dap__number--warn]="daysToInactive <= 30">
|
<span class="dap__number" [class.dap__number--warn]="daysToInactive <= 30">
|
||||||
{{ daysToInactive }}<small>days</small>
|
{{ daysToInactive }}<small>days</small>
|
||||||
@@ -217,6 +220,9 @@
|
|||||||
@if (statusId >= 200) {
|
@if (statusId >= 200) {
|
||||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||||
<span class="dap__card-hint">Account is dormant</span>
|
<span class="dap__card-hint">Account is dormant</span>
|
||||||
|
} @else if (isOverdue(daysToDormancy)) {
|
||||||
|
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToDormancy! }}d</span>
|
||||||
|
<span class="dap__card-hint">{{ dormancySubtext() }}</span>
|
||||||
} @else if (daysToDormancy !== null) {
|
} @else if (daysToDormancy !== null) {
|
||||||
<span class="dap__number" [class.dap__number--warn]="daysToDormancy <= 30">
|
<span class="dap__number" [class.dap__number--warn]="daysToDormancy <= 30">
|
||||||
{{ daysToDormancy }}<small>days</small>
|
{{ daysToDormancy }}<small>days</small>
|
||||||
@@ -239,6 +245,9 @@
|
|||||||
@if (statusId >= 300) {
|
@if (statusId >= 300) {
|
||||||
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
|
||||||
<span class="dap__card-hint">Pending regulatory transfer</span>
|
<span class="dap__card-hint">Pending regulatory transfer</span>
|
||||||
|
} @else if (isOverdue(daysToEscheat)) {
|
||||||
|
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToEscheat! }}d</span>
|
||||||
|
<span class="dap__card-hint">{{ escheatSubtext() }}</span>
|
||||||
} @else if (daysToEscheat !== null) {
|
} @else if (daysToEscheat !== null) {
|
||||||
<span class="dap__number" [class.dap__number--warn]="daysToEscheat <= 30">
|
<span class="dap__number" [class.dap__number--warn]="daysToEscheat <= 30">
|
||||||
{{ daysToEscheat }}<small>days</small>
|
{{ daysToEscheat }}<small>days</small>
|
||||||
@@ -295,7 +304,9 @@
|
|||||||
{{ step.label }}
|
{{ step.label }}
|
||||||
<i class="fas fa-info-circle dap__tl-info"></i>
|
<i class="fas fa-info-circle dap__tl-info"></i>
|
||||||
</span>
|
</span>
|
||||||
@if (!step.reached && step.days !== null) {
|
@if (!step.reached && isOverdue(step.days)) {
|
||||||
|
<span class="dap__tl-reached-tag">Overdue {{ -step.days! }}d</span>
|
||||||
|
} @else if (!step.reached && step.days !== null) {
|
||||||
<span class="dap__tl-days">{{ step.days }}d</span>
|
<span class="dap__tl-days">{{ step.days }}d</span>
|
||||||
} @else if (step.reached) {
|
} @else if (step.reached) {
|
||||||
<span class="dap__tl-reached-tag">Reached</span>
|
<span class="dap__tl-reached-tag">Reached</span>
|
||||||
|
|||||||
@@ -113,12 +113,10 @@ export class DormancyAlertPanelComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.computeRiskFromAccount();
|
this.computeRiskFromAccount();
|
||||||
this.shouldShow =
|
// Only surface this panel once the account has actually left "Active" —
|
||||||
this.isAlertEnabled ||
|
// i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account
|
||||||
this.isMonitoringActive ||
|
// (statusId === 0) should never show a danger-colored dormancy alert.
|
||||||
this.statusId > 0 ||
|
this.shouldShow = this.statusId > 0;
|
||||||
(this.daysToInactive !== null && this.daysToInactive <= 90) ||
|
|
||||||
!!this.lastTxnDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeRiskFromAccount(): void {
|
private computeRiskFromAccount(): void {
|
||||||
@@ -179,17 +177,36 @@ export class DormancyAlertPanelComponent implements OnInit {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** e.g. "Inactive after 30 days of no transaction" */
|
/** true when the threshold date has already passed but the batch job hasn't reclassified the account yet */
|
||||||
|
isOverdue(days: number | null): boolean {
|
||||||
|
return days !== null && days < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. "11d" when still counting down, "Overdue 5d" when the threshold date already passed */
|
||||||
|
daysLabel(days: number | null): string {
|
||||||
|
if (days === null) return '';
|
||||||
|
return this.isOverdue(days) ? `Overdue ${Math.abs(days)}d` : `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. "Moves Active → Inactive after 30 days" */
|
||||||
inactiveSubtext(): string {
|
inactiveSubtext(): string {
|
||||||
return this.daysToInactive !== null ? `No transaction for ${this.daysToInactive} days` : 'Threshold not configured';
|
if (this.daysToInactive === null) return 'Threshold not configured';
|
||||||
|
return this.isOverdue(this.daysToInactive)
|
||||||
|
? `Active → Inactive was due ${Math.abs(this.daysToInactive)} days ago`
|
||||||
|
: `Moves Active → Inactive after ${this.daysToInactive} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
dormancySubtext(): string {
|
dormancySubtext(): string {
|
||||||
const total = (this.daysToInactive ?? 0) + (this.daysToDormancy ?? 0);
|
if (this.daysToDormancy === null) return 'Threshold not configured';
|
||||||
return this.daysToDormancy !== null ? `${this.daysToDormancy} days after inactive` : 'Threshold not configured';
|
return this.isOverdue(this.daysToDormancy)
|
||||||
|
? `Inactive → Dormant was due ${Math.abs(this.daysToDormancy)} days ago`
|
||||||
|
: `Moves Inactive → Dormant after ${this.daysToDormancy} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
escheatSubtext(): string {
|
escheatSubtext(): string {
|
||||||
return this.daysToEscheat !== null ? `${this.daysToEscheat} days after dormant` : 'Threshold not configured';
|
if (this.daysToEscheat === null) return 'Threshold not configured';
|
||||||
|
return this.isOverdue(this.daysToEscheat)
|
||||||
|
? `Dormant → Escheat was due ${Math.abs(this.daysToEscheat)} days ago`
|
||||||
|
: `Moves Dormant → Escheat after ${this.daysToEscheat} days`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ import {
|
|||||||
faClipboardList,
|
faClipboardList,
|
||||||
faMap,
|
faMap,
|
||||||
faCamera,
|
faCamera,
|
||||||
|
faVideo,
|
||||||
faSlidersH,
|
faSlidersH,
|
||||||
faPrint,
|
faPrint,
|
||||||
faCertificate,
|
faCertificate,
|
||||||
@@ -401,6 +402,7 @@ export class IconsModule {
|
|||||||
faClipboardList,
|
faClipboardList,
|
||||||
faMap,
|
faMap,
|
||||||
faCamera,
|
faCamera,
|
||||||
|
faVideo,
|
||||||
faSlidersH,
|
faSlidersH,
|
||||||
faPrint,
|
faPrint,
|
||||||
faCertificate,
|
faCertificate,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a `data:` URL (e.g. produced by `HTMLCanvasElement.toDataURL()` in the
|
||||||
|
* webcam-capture dialog) into a Blob, preserving the embedded MIME type.
|
||||||
|
*/
|
||||||
|
export function dataUrlToBlob(dataUrl: string): Blob {
|
||||||
|
const [
|
||||||
|
header,
|
||||||
|
base64
|
||||||
|
] = dataUrl.split(',');
|
||||||
|
const mimeMatch = /data:(.*?);base64/.exec(header || '');
|
||||||
|
const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg';
|
||||||
|
const binary = atob(base64 || '');
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new Blob([bytes], { type: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a `data:` URL into a `File` so it can be routed through the same
|
||||||
|
* upload/crop pipelines used for file-picker input (multipart form posts, the
|
||||||
|
* image-crop dialog, etc).
|
||||||
|
*/
|
||||||
|
export function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||||
|
const blob = dataUrlToBlob(dataUrl);
|
||||||
|
return new File([blob], filename, { type: blob.type || 'image/jpeg' });
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user