Compare commits
19
Commits
a9c60083bf
...
f687e1808d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 |
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -394,7 +394,12 @@ export class JointHolderDialogComponent implements OnInit {
|
||||
this.kyc = kycArr[0] ?? null;
|
||||
this.photo = photo ?? null;
|
||||
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;
|
||||
if (sigDoc) {
|
||||
this.http
|
||||
@@ -1868,7 +1873,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||
this.clientPhotoUrl = r.photo ?? null;
|
||||
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) {
|
||||
this.http
|
||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||
|
||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document named 'clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
// 2. Signature — client document named 'clientSignature'. Multiple rows 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 signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.viewDetails.docs.signature = {
|
||||
present: true,
|
||||
|
||||
@@ -66,9 +66,12 @@ export class SignatoryService {
|
||||
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||
switchMap((docs: any[]) => {
|
||||
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) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
+16
@@ -62,6 +62,22 @@
|
||||
</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 -->
|
||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||
<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 { ClientFamilyMemberDialogComponent } 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (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;
|
||||
}
|
||||
|
||||
/** 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" */
|
||||
get isMarried(): boolean {
|
||||
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);
|
||||
}
|
||||
+197
-70
@@ -136,7 +136,7 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-label>
|
||||
<input matInput required formControlName="fullname" />
|
||||
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||
@@ -214,7 +214,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field first-name">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<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')) {
|
||||
<mat-error>First name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||
@@ -228,7 +228,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||
<input matInput formControlName="middlename" />
|
||||
<input matInput formControlName="middlename" autocomplete="off" />
|
||||
@if (
|
||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||
createClientForm.controls.middlename?.touched
|
||||
@@ -241,7 +241,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field last-name">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<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')) {
|
||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||
@@ -358,11 +358,14 @@
|
||||
@if (isMinor) {
|
||||
<span class="minor-badge">Minor</span>
|
||||
}
|
||||
@if (isSeniorCitizen) {
|
||||
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="guardian-section">
|
||||
<!-- Header bar -->
|
||||
@@ -1032,69 +1035,67 @@
|
||||
Education & Social Details
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<!-- Education Level (non-minor) -->
|
||||
@if (!isMinor) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Education Level -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Religion -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1217,21 +1218,25 @@
|
||||
</mat-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>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @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-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-label>Agriculture Income</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
@@ -1265,6 +1270,128 @@
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -56,6 +56,78 @@ form {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -234,6 +306,18 @@ form {
|
||||
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 {
|
||||
margin-top: 12px;
|
||||
|
||||
+352
-6
@@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||
@@ -93,6 +94,148 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
/** Expose enum to template */
|
||||
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. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
@@ -155,8 +298,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
];
|
||||
/** Calculated age detail string (Y/M/D) */
|
||||
calculatedAgeDetail: string | null = null;
|
||||
/** Whether client is a minor (age < 10) */
|
||||
/** Whether client is a minor (age < 18) */
|
||||
isMinor = false;
|
||||
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
||||
isSeniorCitizen = false;
|
||||
/** Age breakdown for DB storage */
|
||||
ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||
|
||||
@@ -402,6 +547,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
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
|
||||
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
||||
this.maritalStatusOptions = options || [];
|
||||
@@ -466,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
|
||||
* when the user clicks "Create as Client" on the family members tab.
|
||||
@@ -505,6 +707,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
*/
|
||||
private prefillForEdit() {
|
||||
const data = this.clientData;
|
||||
this.loadExistingSeniorPwdDocuments();
|
||||
|
||||
// Patch all base controls that exist on the form
|
||||
this.createClientForm.patchValue({
|
||||
@@ -521,11 +724,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
mobileNo: data.mobileNo,
|
||||
alternateMobileNo: data.alternateMobileNo || '',
|
||||
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 || '',
|
||||
occupationId: data.occupation?.id || data.occupationId || '',
|
||||
monthlyIncome: data.monthlyIncome || '',
|
||||
annualIncome: data.annualIncome || '',
|
||||
annualTurnover: data.annualTurnover || '',
|
||||
monthlyTurnover: data.monthlyTurnover || '',
|
||||
isPwd: data.isPwd || false,
|
||||
pwdCategoryId: data.pwdCategory?.id || '',
|
||||
agricultureIncome: data.agricultureIncome || '',
|
||||
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
||||
submittedOnDate: data.timeline?.submittedOnDate
|
||||
@@ -599,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,6 +912,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
annualIncome: [
|
||||
'',
|
||||
[
|
||||
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
annualTurnover: [
|
||||
'',
|
||||
[
|
||||
@@ -672,6 +926,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
monthlyTurnover: [
|
||||
'',
|
||||
[
|
||||
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
isPwd: [false],
|
||||
pwdCategoryId: [''],
|
||||
agricultureIncome: [
|
||||
'',
|
||||
[
|
||||
@@ -709,6 +972,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
||||
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
||||
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
||||
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -824,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.ageBreakdown = { years, months, days };
|
||||
const wasMinor = this.isMinor;
|
||||
this.isMinor = years < 10;
|
||||
this.isMinor = years < 18;
|
||||
this.isSeniorCitizen = years >= 60;
|
||||
// Add/remove guardian form group based on minor status
|
||||
if (this.isMinor && !wasMinor) {
|
||||
this.createClientForm.addControl(
|
||||
@@ -850,12 +1115,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
} else {
|
||||
this.calculatedAgeDetail = null;
|
||||
this.isMinor = false;
|
||||
this.isSeniorCitizen = false;
|
||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||
this.createClientForm.removeControl('guardianDetails');
|
||||
}
|
||||
} else {
|
||||
this.calculatedAgeDetail = null;
|
||||
this.isMinor = false;
|
||||
this.isSeniorCitizen = false;
|
||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||
this.createClientForm.removeControl('guardianDetails');
|
||||
}
|
||||
@@ -1548,6 +1815,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
||||
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
||||
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 (
|
||||
n.includes('school') ||
|
||||
n.includes('10th') ||
|
||||
@@ -1556,7 +1826,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
n.includes('hsc') ||
|
||||
n.includes('matric') ||
|
||||
n.includes('intermediate') ||
|
||||
n.includes('diploma') ||
|
||||
n.includes('iti') ||
|
||||
n.includes('below')
|
||||
)
|
||||
@@ -1634,8 +1903,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
}
|
||||
|
||||
/**
|
||||
* Degrees filtered by the currently selected Education Level.
|
||||
* Returns all degrees if no level is selected, or category can't be determined.
|
||||
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
|
||||
* 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[] {
|
||||
const level = this.getSelectedEducationLevel();
|
||||
@@ -1643,6 +1916,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const category = this.getEducationLevelCategory(level.name);
|
||||
if (!category) return this.degreeQualificationOptions;
|
||||
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
||||
if (d.description) {
|
||||
return this.getEducationLevelCategory(d.description) === category;
|
||||
}
|
||||
const c = this.getDegreeQualificationCategory(d.name);
|
||||
return c === category || c === '';
|
||||
});
|
||||
@@ -1805,6 +2081,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
// Store extended fields separately (not yet supported by Fineract API)
|
||||
// Add isMinor flag
|
||||
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
|
||||
if (this.isMinor && generalDetails.guardianDetails) {
|
||||
@@ -1855,6 +2133,74 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
return generalDetails;
|
||||
}
|
||||
|
||||
get calculatedRisk(): { category: number; reason: 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) {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Default Fallbacks
|
||||
if (legalFormId !== LegalFormId.PERSON) {
|
||||
return { category: 2, reason: 'Non-Individual / Entity profile (Default Medium Risk)' };
|
||||
}
|
||||
return { category: 1, reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
</p>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</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 class="escalate-banner">
|
||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
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' });
|
||||
progressValue = 0;
|
||||
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
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) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
|
||||
+3
-2
@@ -173,8 +173,9 @@
|
||||
@if (selectedAddrProof.requiresDate) {
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Issue / bill date</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
||||
<input matInput type="date" formControlName="issueDate" />
|
||||
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
+114
-25
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-india-address-step',
|
||||
@@ -38,11 +41,20 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
private http = inject(HttpClient);
|
||||
private clientsService = inject(ClientsService);
|
||||
private kycUpload = inject(KycUploadHelperService);
|
||||
private dialog = inject(MatDialog);
|
||||
private dates = inject(Dates);
|
||||
|
||||
/** Client ID — when set, loads existing addresses for edit mode */
|
||||
@Input() clientId: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@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;
|
||||
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 {
|
||||
this.loadingProofs = true;
|
||||
this.clientsService.getKycProofTypes().subscribe({
|
||||
next: (res: any) => {
|
||||
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.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 {
|
||||
this.selectedAddrProof = proof;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||
return {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||
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,
|
||||
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
|
||||
// shows what was previously entered.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
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 a new browser tab.
|
||||
*
|
||||
* For images we already hold a base64 data URL in `previewUrl` from
|
||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
||||
* For PDFs / other types we need to fetch the binary blob and create a
|
||||
* temporary object URL so the browser's PDF viewer can render it.
|
||||
*/
|
||||
/** Open the saved address proof in an in-app preview dialog. */
|
||||
viewExistingAddressProof(): void {
|
||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingAddressProofDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
// Revoke after the tab has had time to load — browsers vary, so
|
||||
// err on the side of a generous wait.
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
// Silent fail — UI shows the saved name so user knows it exists.
|
||||
}
|
||||
|
||||
@@ -28,33 +28,36 @@
|
||||
<div class="form-grid">
|
||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||
@if (!isNonIndividual) {
|
||||
@if (readOnly) {
|
||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<div class="ro-flag">
|
||||
<span class="ro-flag__label">Client has PAN Card</span>
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
<span class="ro-flag__chip ro-flag__chip--yes">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Yes
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ro-flag__chip ro-flag__chip--no">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
No
|
||||
</span>
|
||||
}
|
||||
<!-- Minors don't hold a PAN card — skip the PAN checkbox/section entirely -->
|
||||
@if (!isMinor) {
|
||||
@if (readOnly) {
|
||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<div class="ro-flag">
|
||||
<span class="ro-flag__label">Client has PAN Card</span>
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
<span class="ro-flag__chip ro-flag__chip--yes">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Yes
|
||||
</span>
|
||||
} @else {
|
||||
<span class="ro-flag__chip ro-flag__chip--no">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
No
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-field checkbox-field full-width">
|
||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (kycForm.get('hasPan')?.value) {
|
||||
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
@@ -158,6 +161,17 @@
|
||||
}
|
||||
</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') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@@ -1035,6 +1049,12 @@
|
||||
</h4>
|
||||
|
||||
<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-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
@@ -1072,6 +1092,64 @@
|
||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||
</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-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||
<textarea
|
||||
@@ -1266,11 +1344,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
||||
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 60 / 61
|
||||
Form 97 / 98
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
@@ -1278,14 +1356,14 @@
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<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 class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 60</mat-option>
|
||||
<mat-option value="FORM_61">Form 61</mat-option>
|
||||
<mat-option value="FORM_60">Form 97</mat-option>
|
||||
<mat-option value="FORM_61">Form 98</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
@@ -1296,26 +1374,26 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @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>
|
||||
|
||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
||||
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||
Form 15G / 15H
|
||||
Form 121
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<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>
|
||||
@if (kycForm.get('form15Applicable')?.value) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form15Type">
|
||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
||||
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1403,8 +1481,8 @@
|
||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamSignature()">
|
||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||
<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) {
|
||||
|
||||
@@ -266,6 +266,23 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -297,8 +314,24 @@
|
||||
.toggle-vis-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px !important;
|
||||
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 */
|
||||
@@ -2240,3 +2273,161 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ import { Dates } from 'app/core/utils/dates';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
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({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
@@ -61,12 +63,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Set by parent after client is created */
|
||||
@Input() clientId: string = '';
|
||||
@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 */
|
||||
@Input() constitutionMeta: any = null;
|
||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||
@Input() entityName: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
@Input() calculatedRisk: { category: number; reason: string } | null = null;
|
||||
|
||||
today = new Date();
|
||||
kycId: string = '';
|
||||
@@ -163,7 +168,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
||||
{ name: 'Voter ID', code: 'VOTER_ID' },
|
||||
{ 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 */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
@@ -245,6 +263,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||
];
|
||||
|
||||
riskCalculationReason = '';
|
||||
showRiskRulesTable = false;
|
||||
|
||||
/** PAN verification */
|
||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
panVerifying = false;
|
||||
@@ -436,6 +457,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocIssueDate: [''],
|
||||
kycDocExpiryDate: [''],
|
||||
riskCategory: [1],
|
||||
allowRiskOverride: [false],
|
||||
remarks: [''],
|
||||
amlCheckStatus: [
|
||||
'CLEAR',
|
||||
@@ -458,22 +480,151 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
form15FY: ['']
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Seed defaults immediately so the picker has options before clientId is set
|
||||
// (template API requires clientId; user can still pick a type pre-save).
|
||||
if (!this.kycDocumentTypes.length) {
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
|
||||
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
|
||||
* all require adult eligibility; National Population Register Letter is deliberately
|
||||
* excluded too (not offered as a minor OVD in this system). */
|
||||
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT',
|
||||
'BIRTH_CERTIFICATE',
|
||||
'SCHOOL_ID_CARD'
|
||||
]);
|
||||
|
||||
/** 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.loadKycTemplate();
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||
this.kycForm.get('riskCategory')?.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 {
|
||||
@@ -491,11 +642,63 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycForm.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.kycForm.enable({ emitEvent: false });
|
||||
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||
this.kycForm.get('riskCategory')?.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();
|
||||
}
|
||||
} else {
|
||||
this.riskCalculationReason = '';
|
||||
}
|
||||
}
|
||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||
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 */
|
||||
@@ -566,30 +769,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved KYC ID-proof document in a new browser tab.
|
||||
*
|
||||
* Same flow as the address-step variant: images use the already-loaded
|
||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
||||
* fresh and rendered via a temporary blob URL.
|
||||
*/
|
||||
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
|
||||
viewExistingKycDoc(): void {
|
||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingKycDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
/* silent — UI still shows the saved filename */
|
||||
}
|
||||
@@ -680,6 +870,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
||||
// Risk & Remarks
|
||||
riskCategory: data.riskCategory || 1,
|
||||
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
||||
remarks: data.remarks || '',
|
||||
hasPan: !!data.panNumber,
|
||||
// AML & Compliance
|
||||
@@ -765,12 +956,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
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
|
||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||
const match = kycDocumentName
|
||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
: null;
|
||||
const identifier = match || identifiers.find(isKycType);
|
||||
const identifier = match || sorted.find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
@@ -845,7 +1041,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
||||
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) {
|
||||
this.existingSignatureDocId = sigDoc.id.toString();
|
||||
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
||||
@@ -874,11 +1075,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycTemplate = template;
|
||||
this.kycTemplateLoaded = true;
|
||||
if (template?.requiredDocuments?.length > 0) {
|
||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}));
|
||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||
template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}))
|
||||
);
|
||||
// Update kycDocumentName validator based on mandatory docs
|
||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||
if (hasMandatory) {
|
||||
@@ -891,7 +1094,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
@@ -1318,6 +1521,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 */
|
||||
verifyAadhaarOtp() {
|
||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||
@@ -1440,20 +1658,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
await this.openSignatureCropDialog(file);
|
||||
}
|
||||
|
||||
/** Webcam capture entry point for the specimen signature — photographs a paper
|
||||
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
|
||||
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
|
||||
async captureWebcamSignature(): Promise<void> {
|
||||
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
||||
data: { captureTarget: 'signature' }
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
||||
if (!imageDataUrl) return;
|
||||
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
|
||||
await this.openSignatureCropDialog(file);
|
||||
});
|
||||
}
|
||||
|
||||
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||
@@ -1463,42 +1667,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
this.signatureFile = result.croppedFile;
|
||||
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: () => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
this.applySignatureFile(result.croppedFile, result.previewUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
this.photographFile = null;
|
||||
this.photographPreview = null;
|
||||
@@ -1523,7 +1736,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
get kycData(): any {
|
||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
||||
const formVal = this.kycForm.getRawValue();
|
||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@
|
||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||
"
|
||||
>
|
||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +191,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'form60',
|
||||
component: Form60TabComponent,
|
||||
data: { title: 'Form 60', breadcrumb: 'Form 60', routeParamBreadcrumb: false }
|
||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'documents',
|
||||
|
||||
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Signature — client document with name='clientSignature'
|
||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
||||
// 2. Signature — client document with name='clientSignature'. Multiple rows 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 signatureDoc = (documents as any[])
|
||||
?.filter((d: any) => d?.name === 'clientSignature')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (signatureDoc) {
|
||||
this.existing.CLIENT_SIGNATURE = {
|
||||
present: true,
|
||||
|
||||
+7
-1
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
|
||||
* @param {any} data Documents data
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -347,9 +347,18 @@
|
||||
<i class="fa fa-calendar"></i> Issue / statement date
|
||||
<span class="kyc-opt">(not older than 3 months)</span>
|
||||
</label>
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||
<input
|
||||
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>
|
||||
}
|
||||
|
||||
+18
-28
@@ -388,42 +388,32 @@ $red-900: #993c1d;
|
||||
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 {
|
||||
font-family: inherit;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
padding-right: 14px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 40px !important;
|
||||
}
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
transition: opacity 0.15s;
|
||||
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $gray-500;
|
||||
|
||||
::ng-deep .mat-mdc-icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:hover::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&::-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;
|
||||
::ng-deep .mat-mdc-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -65,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
|
||||
* @param {any} data Documents data
|
||||
*/
|
||||
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.clientId = this.data.id;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
<div class="f60-pan-ok__icon-wrap">
|
||||
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
||||
</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">
|
||||
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
||||
@if (panVerified) {
|
||||
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="f60-warning">
|
||||
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
||||
<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>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<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>
|
||||
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
@@ -123,7 +123,7 @@
|
||||
<div class="f60-card">
|
||||
<div class="f60-card__header">
|
||||
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
||||
<h3>Form 60 Declaration</h3>
|
||||
<h3>Form 97 Declaration</h3>
|
||||
</div>
|
||||
<div class="f60-card__body">
|
||||
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
||||
@@ -204,7 +204,7 @@
|
||||
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -224,14 +224,14 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
||||
this.isEditMode = false;
|
||||
this.alertService.alert({
|
||||
type: 'success',
|
||||
message: 'Form 60 submitted successfully.'
|
||||
message: 'Form 97 submitted successfully.'
|
||||
});
|
||||
this.loadForm60Data();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.alertService.alert({
|
||||
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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
||||
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
||||
>
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,12 +98,10 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Account no Column -->
|
||||
<!-- Client ID Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
||||
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
|
||||
</td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
|
||||
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
|
||||
private getClients() {
|
||||
this.isLoading = true;
|
||||
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(
|
||||
(data: any) => {
|
||||
this.allRows = data.content || [];
|
||||
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
|
||||
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
||||
clients.forEach((client, i) => {
|
||||
this.clientService
|
||||
.getClientData(client.id?.toString())
|
||||
.getClientData(client.id?.toString(), true)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((data: any) => {
|
||||
if (data?.legalForm) {
|
||||
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.clientService
|
||||
.getClientKyc(client.id?.toString())
|
||||
.getClientKyc(client.id?.toString(), true)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((kyc: any) => {
|
||||
if (kyc) {
|
||||
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.clientService
|
||||
.getClientAddressData(client.id?.toString())
|
||||
.getClientAddressData(client.id?.toString(), true)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((addr: any) => {
|
||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
/** Angular Imports */
|
||||
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 */
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
@@ -66,8 +67,11 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
||||
}
|
||||
|
||||
getClientData(clientId: string) {
|
||||
return this.http.get(`/clients/${clientId}`);
|
||||
getClientData(clientId: string, silent = false) {
|
||||
return this.http.get(
|
||||
`/clients/${clientId}`,
|
||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
createClient(client: any) {
|
||||
@@ -82,6 +86,43 @@ export class ClientsService {
|
||||
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) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
@@ -116,8 +157,11 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/accounts`);
|
||||
}
|
||||
|
||||
getClientAddressData(clientId: string) {
|
||||
return this.http.get(`/client/${clientId}/addresses`);
|
||||
getClientAddressData(clientId: string, silent = false) {
|
||||
return this.http.get(
|
||||
`/client/${clientId}/addresses`,
|
||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
getClientChargesData(clientId: string) {
|
||||
@@ -661,7 +705,8 @@ export class ClientsService {
|
||||
sortAttribute: string = '',
|
||||
sortDirection: string = '',
|
||||
activeOnly: boolean = false,
|
||||
legalFormType: string = ''
|
||||
legalFormType: string = '',
|
||||
silent: boolean = false
|
||||
) {
|
||||
const innerRequest: any = { text };
|
||||
if (activeOnly) {
|
||||
@@ -690,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) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -727,8 +776,11 @@ export class ClientsService {
|
||||
* Gets KYC details for a client.
|
||||
* @param clientId Client ID
|
||||
*/
|
||||
getClientKyc(clientId: string): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/kyc`);
|
||||
getClientKyc(clientId: string, silent = false): Observable<any> {
|
||||
return this.http.get(
|
||||
`/clients/${clientId}/kyc`,
|
||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -739,6 +791,10 @@ export class ClientsService {
|
||||
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 */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
|
||||
@@ -71,10 +71,12 @@
|
||||
<mifosx-client-kyc-step
|
||||
#clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="createdClientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +148,8 @@
|
||||
}
|
||||
<mifosx-client-india-address-step
|
||||
#clientIndiaAddress
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,12 @@ export class CreateClientComponent {
|
||||
this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => {
|
||||
this.clientTemplate = data.clientTemplate;
|
||||
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();
|
||||
});
|
||||
// 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) */
|
||||
isTabReadOnly(tabIndex: number): boolean {
|
||||
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
||||
@@ -218,8 +240,8 @@ export class CreateClientComponent {
|
||||
return this.isAddressStepValid();
|
||||
}
|
||||
if (idx === 3) {
|
||||
// Family tab — relations are optional, always allow.
|
||||
return true;
|
||||
// Family tab — Mother is required, and either Father or a Caretaker.
|
||||
return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -253,6 +275,11 @@ export class CreateClientComponent {
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -331,6 +358,18 @@ export class CreateClientComponent {
|
||||
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
|
||||
if (!this.clientGeneralStep?.sanctionsVerified) {
|
||||
this.dialog
|
||||
@@ -469,6 +508,29 @@ export class CreateClientComponent {
|
||||
clearInterval(progressInterval);
|
||||
this.saveProgress = 100;
|
||||
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
|
||||
setTimeout(() => {
|
||||
this.savingClient = false;
|
||||
@@ -514,6 +576,21 @@ export class CreateClientComponent {
|
||||
this.selectedTabIndex = 3;
|
||||
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 = '';
|
||||
|
||||
if (this.createdClientId) {
|
||||
|
||||
@@ -25,9 +25,22 @@
|
||||
{{ 'labels.inputs.GENERAL' | translate }}
|
||||
</ng-template>
|
||||
<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">
|
||||
@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
|
||||
</button>
|
||||
} @else {
|
||||
@@ -149,8 +162,10 @@
|
||||
<mifosx-client-kyc-step
|
||||
#clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,6 +221,8 @@
|
||||
<mifosx-client-india-address-step
|
||||
#clientIndiaAddress
|
||||
[clientId]="clientId"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +217,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 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 ─── */
|
||||
.rekyc-alert {
|
||||
display: flex;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
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.
|
||||
@@ -116,6 +117,9 @@ export class EditClientComponent {
|
||||
clientDataAndTemplate: any;
|
||||
/** Client ID */
|
||||
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() {
|
||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||
@@ -124,6 +128,15 @@ export class EditClientComponent {
|
||||
if (this.clientDataAndTemplate.legalForm) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,14 +300,20 @@ export class EditClientComponent {
|
||||
if (this.legalFormType === 2) {
|
||||
if (idx === 2) return true; // Scheme & Sector — opt-in
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const step = this.clientIndiaAddressStep;
|
||||
if (!step) return true;
|
||||
@@ -318,6 +337,12 @@ export class EditClientComponent {
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -422,46 +447,51 @@ export class EditClientComponent {
|
||||
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.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: () => {
|
||||
this.savingClient = false;
|
||||
this.tabEditMode[tabIndex] = false;
|
||||
this.hasPendingGeneralEdit = true;
|
||||
|
||||
// Log mobile/email changes as client notes for risk audit
|
||||
this.logFieldChangeNotes(changedFields);
|
||||
|
||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
||||
this.dobChanged = true;
|
||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
||||
this.flagReKycRequired(changedFields);
|
||||
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
||||
// the staged JSON edit, so these still apply immediately as before.
|
||||
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||
const seniorCitizenProofForm = new FormData();
|
||||
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
||||
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
||||
this.clientsService
|
||||
.uploadClientDocument(this.clientId, seniorCitizenProofForm)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
if (this.clientGeneralStep?.pwdCertificateFile) {
|
||||
const pwdCertificateForm = new FormData();
|
||||
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
|
||||
pwdCertificateForm.append('name', 'pwdCertificate');
|
||||
pwdCertificateForm.append('description', 'Disability Certificate');
|
||||
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Update original data snapshot so subsequent edits compare against latest
|
||||
if (changedFields.mobileChanged) {
|
||||
originalData.mobileNo = changedFields.newMobile;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
originalData.emailAddress = changedFields.newEmail;
|
||||
}
|
||||
if (changedFields.dobChanged) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Notification is backend-driven (SMS + Email) — just reassure staff it happened.
|
||||
if (changedFields.mobileChanged || changedFields.emailChanged) {
|
||||
this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
this.snackBar.open(
|
||||
'Edit submitted for approval — a different user must review it before it takes effect.',
|
||||
'Close',
|
||||
{
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
this.resetContactVerificationState();
|
||||
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
|
||||
@@ -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',
|
||||
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: {
|
||||
sides: 1,
|
||||
category: 'Utility bills',
|
||||
@@ -131,7 +145,46 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
||||
PENSION_PAYMENT_ORDER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
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.'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
|
||||
import { Logger } from '../logger/logger.service';
|
||||
import { AlertService } from '../alert/alert.service';
|
||||
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
||||
import { SILENT_HTTP_ERROR } from './silent-error.token';
|
||||
|
||||
/** Initialize Logger */
|
||||
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 isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||
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}`);
|
||||
}
|
||||
|
||||
if (isSilentOverride) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Silently handle expected 403/404 errors (no image, no KYC record)
|
||||
// Throw instead of EMPTY so component-level catchError can handle it
|
||||
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);
|
||||
@@ -1468,13 +1468,13 @@
|
||||
Balance Certificate
|
||||
</button>
|
||||
<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 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 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 mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||
@@ -1568,8 +1568,12 @@
|
||||
<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)
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
||||
(click)="rbiMenuOpen = false; openCtrStrDialog()"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
||||
|
||||
@@ -280,6 +280,23 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the CTR & STR Compliance Center dialog.
|
||||
*/
|
||||
async openCtrStrDialog(): Promise<void> {
|
||||
const { CtrStrDialogComponent } = await import('../../home/dashboard/ctr-str-dialog/ctr-str-dialog.component');
|
||||
this.dialog.open(CtrStrDialogComponent, {
|
||||
width: '95vw',
|
||||
maxWidth: '1280px',
|
||||
maxHeight: '92vh',
|
||||
panelClass: 'crr-slr-dialog-panel',
|
||||
autoFocus: false,
|
||||
disableClose: false,
|
||||
hasBackdrop: true,
|
||||
backdropClass: 'ckyc-rpt-dlg-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
|
||||
* is selected based on the menu link the user clicked; user can switch between
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<!--
|
||||
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="ctr-str-dlg">
|
||||
<!-- Dialog Header -->
|
||||
<div class="dlg-header">
|
||||
<div class="dlg-title-area">
|
||||
<fa-icon icon="shield-alt" class="header-icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="title">CTR & STR Compliance Control Center</h2>
|
||||
<p class="subtitle">Anti-Money Laundering (AML) & CFT Regulatory Reporting Terminal</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button [mat-dialog-close]="true" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigators -->
|
||||
<div class="tab-navs">
|
||||
<button class="tab-btn" [class.active]="activeTab === 'dashboard'" (click)="activeTab = 'dashboard'">
|
||||
<fa-icon icon="chart-bar" class="tab-icon"></fa-icon> Compliance Dashboard
|
||||
</button>
|
||||
<button class="tab-btn" [class.active]="activeTab === 'guidelines'" (click)="activeTab = 'guidelines'">
|
||||
<fa-icon icon="university" class="tab-icon"></fa-icon> RBI Regulatory Guidelines
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Body (Tabs) -->
|
||||
<div class="dlg-body">
|
||||
<!-- TAB 1: COMPLIANCE DASHBOARD -->
|
||||
@if (activeTab === 'dashboard') {
|
||||
<div class="tab-content dashboard-tab">
|
||||
<!-- SECTION 1: CTR MANAGMENT -->
|
||||
<div class="dashboard-section card-glass">
|
||||
<div class="section-hdr">
|
||||
<div>
|
||||
<h3>Cash Transaction Reports (CTR - Monthly Cash Registry)</h3>
|
||||
<p class="section-sub">
|
||||
Scans and compiles cash transactions exceeding INR 10 Lakhs limit (Single or monthly cumulative cash
|
||||
deposits/withdrawals).
|
||||
</p>
|
||||
</div>
|
||||
<button class="action-btn primary" [disabled]="isGeneratingCtr" (click)="generateCtrReport()">
|
||||
@if (isGeneratingCtr) {
|
||||
<span class="spinner"></span> Generating Report...
|
||||
} @else {
|
||||
<fa-icon icon="sync-alt"></fa-icon> Generate Monthly CTR
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search CTR files by month or name..."
|
||||
[(ngModel)]="ctrFilter"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="compliance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Report Payload Name</th>
|
||||
<th>Reporting Period</th>
|
||||
<th>Record Count</th>
|
||||
<th>Generated By</th>
|
||||
<th>Generated Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (report of filteredCtrReports; track report.id) {
|
||||
<tr>
|
||||
<td class="bold-text"><fa-icon icon="file-code" class="xml-icon"></fa-icon> {{ report.name }}</td>
|
||||
<td>{{ report.month }}</td>
|
||||
<td class="center-text">
|
||||
<span class="cnt-badge">{{ report.recordCount }} txns</span>
|
||||
</td>
|
||||
<td>{{ report.generatedBy }}</td>
|
||||
<td>{{ report.generatedAt | date: 'medium' }}</td>
|
||||
<td><span class="status-badge ready">Ready</span></td>
|
||||
<td>
|
||||
<button
|
||||
class="icon-action-btn"
|
||||
matTooltip="Download FIU-IND XML Payload"
|
||||
(click)="downloadCtrFile(report.name)"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon> Download XML
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">No CTR reports found matching your search.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: STR ALERTS PIPELINE -->
|
||||
<div class="dashboard-section card-glass mt-20">
|
||||
<div class="section-hdr">
|
||||
<div>
|
||||
<h3>Suspicious Transaction Activity Pipeline (STR Management)</h3>
|
||||
<p class="section-sub">
|
||||
Behavioral & structural AML alerts generated by KYC profile rules engine. Investigator actions update
|
||||
state in real-time.
|
||||
</p>
|
||||
</div>
|
||||
<button class="action-btn secondary" (click)="downloadStrReportTemplate()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export Active Alerts (CSV)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-actions">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search alerts by Client, CIF, or alert type..."
|
||||
[(ngModel)]="strFilter"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="alerts-grid">
|
||||
@for (alert of filteredStrAlerts; track alert.id) {
|
||||
<div class="alert-card" [class]="alert.status.toLowerCase().replace(' ', '-')">
|
||||
<div class="card-hdr">
|
||||
<div class="client-info">
|
||||
<h4>{{ alert.clientName }}</h4>
|
||||
<span class="cif-number">{{ alert.clientCif }}</span>
|
||||
</div>
|
||||
<span class="status-badge" [class]="alert.status.toLowerCase().replace(' ', '-')">{{
|
||||
alert.status
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="amount-line">
|
||||
<span class="label">Amount Highlighted:</span>
|
||||
<span class="amount">₹{{ alert.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="type-line">
|
||||
<span class="label">Alert Flag:</span>
|
||||
<span class="type-value">{{ alert.alertType }}</span>
|
||||
</div>
|
||||
<p class="desc">{{ alert.description }}</p>
|
||||
<span class="date-stamp"
|
||||
><fa-icon icon="clock"></fa-icon> Flagged Date: {{ alert.date | date: 'mediumDate' }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
@if (alert.status === 'Open') {
|
||||
<button class="card-btn investigate" (click)="changeAlertStatus(alert.id, 'Investigating')">
|
||||
<fa-icon icon="search"></fa-icon> Investigate
|
||||
</button>
|
||||
}
|
||||
@if (alert.status === 'Open' || alert.status === 'Investigating') {
|
||||
<button class="card-btn file-str" (click)="changeAlertStatus(alert.id, 'Reported STR')">
|
||||
<fa-icon icon="flag"></fa-icon> File STR
|
||||
</button>
|
||||
<button class="card-btn close-alert" (click)="changeAlertStatus(alert.id, 'Closed')">
|
||||
<fa-icon icon="ban"></fa-icon> Dismiss
|
||||
</button>
|
||||
}
|
||||
@if (alert.status === 'Closed' || alert.status === 'Reported STR') {
|
||||
<button class="card-btn reset-alert" (click)="changeAlertStatus(alert.id, 'Open')">
|
||||
<fa-icon icon="redo-alt"></fa-icon> Reopen Alert
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-alerts">No Suspicious Transaction alerts matching your criteria.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 2: RBI REGULATORY GUIDELINES -->
|
||||
@if (activeTab === 'guidelines') {
|
||||
<div class="tab-content scroll-y">
|
||||
<div class="info-layout">
|
||||
<!-- CTR Rules Card -->
|
||||
<div class="info-card card-glass">
|
||||
<div class="card-title-badge ctr-badge-color">
|
||||
<fa-icon icon="coins" class="badge-icon"></fa-icon> CTR - Cash Transaction Report
|
||||
</div>
|
||||
<div class="rules-body">
|
||||
<p class="lead-desc">
|
||||
As mandated by Section 12 of the **Prevention of Money Laundering Act (PMLA), 2002** and RBI KYC
|
||||
guidelines, banks must record and report all high-value cash transactions.
|
||||
</p>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>1. Transaction Threshold</h5>
|
||||
<p>
|
||||
All cash transactions (either deposits or withdrawals) of **value more than INR 10 Lakhs** (or its
|
||||
equivalent in foreign currency) in a single transaction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>2. Monthly Aggregation Rule (Structuring check)</h5>
|
||||
<p>
|
||||
A series of cash transactions in a calendar month that are **integrally connected** and accumulate to
|
||||
a total value exceeding **INR 10 Lakhs** must be compiled and reported in the monthly CTR.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>3. Reporting Timeline</h5>
|
||||
<p>
|
||||
CTRs must be generated and uploaded to the FIU-IND gateway **by the 15th day of the succeeding month**
|
||||
(e.g., June transactions must be uploaded by July 15th).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point text-warning-border">
|
||||
<strong>Important Note:</strong> Transactions where the net cash balance does not change but high volume
|
||||
is rotated (e.g., cash withdrawals immediately deposited back) must be reported under CTR as separate
|
||||
debit and credit records.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STR Rules Card -->
|
||||
<div class="info-card card-glass">
|
||||
<div class="card-title-badge str-badge-color">
|
||||
<fa-icon icon="flag" class="badge-icon"></fa-icon> STR - Suspicious Transaction Report
|
||||
</div>
|
||||
<div class="rules-body">
|
||||
<p class="lead-desc">
|
||||
Every reporting entity must maintain vigilance over account transactions. If there are reasonable
|
||||
grounds to suspect transactions involve proceeds of crime, an STR must be generated.
|
||||
</p>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>1. What Constitutes Suspicion?</h5>
|
||||
<p>
|
||||
Transactions that are complex, show unusual patterns, mismatch the customer’s business/risk profile,
|
||||
have no apparent economic rationale, or involve suspected proceeds of crime or terror funding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>2. No Minimum Threshold Limit</h5>
|
||||
<p>
|
||||
**There is no minimum monetary limit** for STR. An alert should be generated for any transaction,
|
||||
regardless of size, if suspicious behavior is flagged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point">
|
||||
<h5>3. Reporting Timeline</h5>
|
||||
<p>
|
||||
The STR must be filed to FIU-IND **within 7 days** of the Principal Compliance Officer confirming the
|
||||
suspicion.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rule-point text-danger-border">
|
||||
<strong>Strict Tipping-off Restriction:</strong> It is illegal to disclose or "tip-off" to the client
|
||||
that their account is being monitored, investigated, or reported under an STR. Complete confidentiality
|
||||
must be maintained.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.ctr-str-dlg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 90vh;
|
||||
max-height: 820px;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-family: Inter, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Header
|
||||
.dlg-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.dlg-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 26px;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: #64748b;
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation Tabs
|
||||
.tab-navs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
padding: 8px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e0e7ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll layout
|
||||
.scroll-y {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Dialog Body
|
||||
.dlg-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
background: #f1f5f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.scroll-y {
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard Tab specific layout
|
||||
.dashboard-tab {
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
// Card Style Container
|
||||
.card-glass {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 5%),
|
||||
0 2px 4px -1px rgb(0 0 0 / 2%);
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.section-hdr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mt-25 {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
// Search input
|
||||
.table-actions {
|
||||
margin-bottom: 14px;
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgb(79 70 229 / 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Button styles
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0284c7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner animation
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgb(255 255 255 / 30%);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Tables styling
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.compliance-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #334155;
|
||||
|
||||
&.bold-text {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&.center-text {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cnt-badge {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xml-icon {
|
||||
color: #ea580c;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 24px;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Action Button
|
||||
.icon-action-btn {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #475569;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert Cards Grid
|
||||
.alerts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
// Left boundary color codes based on status
|
||||
&.open {
|
||||
border-left: 4px solid #dc2626;
|
||||
}
|
||||
|
||||
&.investigating {
|
||||
border-left: 4px solid #ea580c;
|
||||
}
|
||||
|
||||
&.reported-str {
|
||||
border-left: 4px solid #16a34a;
|
||||
}
|
||||
|
||||
&.closed {
|
||||
border-left: 4px solid #64748b;
|
||||
}
|
||||
|
||||
.card-hdr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.cif-number {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
|
||||
.amount-line,
|
||||
.type-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.type-value {
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 8px 0;
|
||||
line-height: 1.4;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #e2e8f0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.date-stamp {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 12px;
|
||||
|
||||
.card-btn {
|
||||
flex: 1;
|
||||
min-width: 75px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.investigate {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
|
||||
&:hover {
|
||||
background: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
&.file-str {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
|
||||
&:hover {
|
||||
background: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
&.close-alert {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
&.reset-alert {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Badges
|
||||
.status-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ready {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.open {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.investigating {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.reported-str {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.closed {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-alerts {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 36px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Tab 2: Guidelines layout
|
||||
.info-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
|
||||
@media (width >= 900px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.card-title-badge {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ctr-badge-color {
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
.str-badge-color {
|
||||
background: #be185d;
|
||||
}
|
||||
|
||||
.rules-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
|
||||
.lead-desc {
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rule-point {
|
||||
margin-bottom: 18px;
|
||||
|
||||
h5 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.text-warning-border {
|
||||
border-left: 3px solid #eab308;
|
||||
background: #fef9c3;
|
||||
padding: 10px 14px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.text-danger-border {
|
||||
border-left: 3px solid #ef4444;
|
||||
background: #fee2e2;
|
||||
padding: 10px 14px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 3: Architecture & Schema
|
||||
.arch-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 4px 0 20px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.arch-section {
|
||||
h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
|
||||
&.plain {
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
// API Routes
|
||||
.route-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.method {
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
|
||||
&.get {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
&.put {
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
&.post {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.path {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.route-desc {
|
||||
color: #475569;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
||||
import {
|
||||
faShieldAlt,
|
||||
faChartBar,
|
||||
faUniversity,
|
||||
faCode,
|
||||
faSyncAlt,
|
||||
faFileExcel,
|
||||
faFileCode,
|
||||
faDownload,
|
||||
faClock,
|
||||
faSearch,
|
||||
faFlag,
|
||||
faBan,
|
||||
faRedoAlt,
|
||||
faCoins
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
interface CtrReport {
|
||||
id: number;
|
||||
name: string;
|
||||
month: string;
|
||||
recordCount: number;
|
||||
generatedBy: string;
|
||||
generatedAt: Date;
|
||||
status: 'Ready' | 'Generating';
|
||||
}
|
||||
|
||||
interface StrAlert {
|
||||
id: number;
|
||||
clientName: string;
|
||||
clientCif: string;
|
||||
alertType: string;
|
||||
amount: number;
|
||||
status: 'Open' | 'Investigating' | 'Reported STR' | 'Closed';
|
||||
description: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-ctr-str-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './ctr-str-dialog.component.html',
|
||||
styleUrls: ['./ctr-str-dialog.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatTabsModule,
|
||||
MatTooltipModule,
|
||||
MatIconModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class CtrStrDialogComponent {
|
||||
dialogRef = inject(MatDialogRef<CtrStrDialogComponent>);
|
||||
|
||||
constructor() {
|
||||
const library = inject(FaIconLibrary);
|
||||
library.addIcons(
|
||||
faShieldAlt,
|
||||
faChartBar,
|
||||
faUniversity,
|
||||
faCode,
|
||||
faSyncAlt,
|
||||
faFileExcel,
|
||||
faFileCode,
|
||||
faDownload,
|
||||
faClock,
|
||||
faSearch,
|
||||
faFlag,
|
||||
faBan,
|
||||
faRedoAlt,
|
||||
faCoins
|
||||
);
|
||||
}
|
||||
|
||||
activeTab: 'dashboard' | 'guidelines' = 'dashboard';
|
||||
|
||||
// Mock State
|
||||
isGeneratingCtr = false;
|
||||
ctrFilter = '';
|
||||
strFilter = '';
|
||||
|
||||
ctrReports: CtrReport[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'CTR_REPORT_JUNE_2026.xml',
|
||||
month: 'June 2026',
|
||||
recordCount: 14,
|
||||
generatedBy: 'Compliance_Officer_1',
|
||||
generatedAt: new Date(2026, 6, 5, 10, 30),
|
||||
status: 'Ready'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'CTR_REPORT_MAY_2026.xml',
|
||||
month: 'May 2026',
|
||||
recordCount: 22,
|
||||
generatedBy: 'Compliance_Officer_1',
|
||||
generatedAt: new Date(2026, 5, 4, 14, 15),
|
||||
status: 'Ready'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'CTR_REPORT_APRIL_2026.xml',
|
||||
month: 'April 2026',
|
||||
recordCount: 9,
|
||||
generatedBy: 'Compliance_Officer_2',
|
||||
generatedAt: new Date(2026, 4, 3, 11, 45),
|
||||
status: 'Ready'
|
||||
}
|
||||
];
|
||||
|
||||
strAlerts: StrAlert[] = [
|
||||
{
|
||||
id: 101,
|
||||
clientName: 'Aarav Sharma',
|
||||
clientCif: 'CIF982348',
|
||||
alertType: 'Profile Mismatch',
|
||||
amount: 450000,
|
||||
status: 'Open',
|
||||
description:
|
||||
'Student account received a large inward transfer (INR 4.5L) mismatching declared zero/student income category.',
|
||||
date: new Date(2026, 6, 26)
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
clientName: 'Karan Malhotra',
|
||||
clientCif: 'CIF229482',
|
||||
alertType: 'Structuring (Smurfing)',
|
||||
amount: 980000,
|
||||
status: 'Investigating',
|
||||
description:
|
||||
'Multiple cash deposits of INR 2.45L, 2.45L, 2.45L, 2.45L within 10 days, totaling INR 9.8L cash (deliberately staying below the 10L single cash transaction CTR threshold).',
|
||||
date: new Date(2026, 6, 24)
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
clientName: 'Nexora Real Estate Ltd',
|
||||
clientCif: 'CIF500481',
|
||||
alertType: 'Turnover Outlier',
|
||||
amount: 15000000,
|
||||
status: 'Open',
|
||||
description:
|
||||
'Entity account registered an outward wire of INR 1.5Cr when maximum monthly turnover limit is set to INR 50L.',
|
||||
date: new Date(2026, 6, 20)
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
clientName: 'Priyanka Patel',
|
||||
clientCif: 'CIF774920',
|
||||
alertType: 'Dormant Account Reactivation',
|
||||
amount: 600000,
|
||||
status: 'Reported STR',
|
||||
description:
|
||||
'Account inactive for 18 months suddenly received a cash deposit of INR 6L, followed by immediate online transfers.',
|
||||
date: new Date(2026, 6, 15)
|
||||
}
|
||||
];
|
||||
|
||||
get filteredCtrReports() {
|
||||
return this.ctrReports.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(this.ctrFilter.toLowerCase()) ||
|
||||
r.month.toLowerCase().includes(this.ctrFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
get filteredStrAlerts() {
|
||||
return this.strAlerts.filter(
|
||||
(a) =>
|
||||
a.clientName.toLowerCase().includes(this.strFilter.toLowerCase()) ||
|
||||
a.clientCif.toLowerCase().includes(this.strFilter.toLowerCase()) ||
|
||||
a.alertType.toLowerCase().includes(this.strFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Actions
|
||||
generateCtrReport() {
|
||||
if (this.isGeneratingCtr) {
|
||||
return;
|
||||
}
|
||||
this.isGeneratingCtr = true;
|
||||
|
||||
// Simulate API call to Spring Boot batch job
|
||||
setTimeout(() => {
|
||||
const date = new Date();
|
||||
const newReport: CtrReport = {
|
||||
id: this.ctrReports.length + 1,
|
||||
name: `CTR_REPORT_JULY_2026_DRAFT.xml`,
|
||||
month: 'July 2026 (Draft)',
|
||||
recordCount: 8,
|
||||
generatedBy: 'Current_Admin',
|
||||
generatedAt: date,
|
||||
status: 'Ready'
|
||||
};
|
||||
this.ctrReports.unshift(newReport);
|
||||
this.isGeneratingCtr = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
downloadCtrFile(reportName: string) {
|
||||
const dummyXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<FIU-IND-CTR-Report xmlns="http://fiuindia.gov.in/ctr/v1">
|
||||
<Header>
|
||||
<ReportType>CTR</ReportType>
|
||||
<ReportingEntityName>CBS Nexora Bank</ReportingEntityName>
|
||||
<ReportingPeriod>2026-06</ReportingPeriod>
|
||||
</Header>
|
||||
<ReportData>
|
||||
<Record>
|
||||
<TransactionID>TXN88924824</TransactionID>
|
||||
<Date>2026-06-15</Date>
|
||||
<Mode>CASH</Mode>
|
||||
<Amount>1250000.00</Amount>
|
||||
<AccountType>SAVINGS</AccountType>
|
||||
<CustomerName>Aarav Sharma</CustomerName>
|
||||
<CifNumber>CIF982348</CifNumber>
|
||||
</Record>
|
||||
</ReportData>
|
||||
</FIU-IND-CTR-Report>`;
|
||||
|
||||
const blob = new Blob([dummyXml], { type: 'application/xml' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = reportName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
changeAlertStatus(alertId: number, newStatus: 'Open' | 'Investigating' | 'Reported STR' | 'Closed') {
|
||||
const alert = this.strAlerts.find((a) => a.id === alertId);
|
||||
if (alert) {
|
||||
alert.status = newStatus;
|
||||
}
|
||||
}
|
||||
|
||||
downloadStrReportTemplate() {
|
||||
const dummyCsv =
|
||||
`AlertID,ClientName,CIF,AlertType,Amount,Status,Description\n` +
|
||||
this.strAlerts
|
||||
.map(
|
||||
(a) =>
|
||||
`"${a.id}","${a.clientName}","${a.clientCif}","${a.alertType}","${a.amount}","${a.status}","${a.description.replace(/"/g, '""')}"`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([dummyCsv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `STR_AML_ALERTS_EXPORT.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -969,7 +969,7 @@ export function buildForm60Pdf(r: Form60Record): jsPDF {
|
||||
|
||||
let y = paintLetterhead(
|
||||
c,
|
||||
'Form 60 Declaration',
|
||||
'Form 97 Declaration',
|
||||
'CERTIFICATE REFERENCE',
|
||||
`F60-${r.customerId}`,
|
||||
'PAN Status',
|
||||
|
||||
+3
-3
@@ -85,7 +85,7 @@
|
||||
} @else if (isBalance && balance) {
|
||||
<span class="cpd-stage-pill">{{ balance.asOnDate | date: 'd MMM y' }}</span>
|
||||
} @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) {
|
||||
<span class="cpd-stage-pill cpd-stage-pill--teal">Form {{ form15gh.kind }}</span>
|
||||
} @else if (isHypothecation && hypothecation) {
|
||||
@@ -313,7 +313,7 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- ── FORM 60 ── -->
|
||||
<!-- ── FORM 97 ── -->
|
||||
@if (isForm60 && form60) {
|
||||
<p class="cpd-para">
|
||||
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">
|
||||
<td colspan="3">PAN STATUS</td>
|
||||
<td>
|
||||
<strong>NOT ALLOTTED — Form 60 declared</strong>
|
||||
<strong>NOT ALLOTTED — Form 97 declared</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
+2
-2
@@ -151,9 +151,9 @@ export class CertificatePreviewDialogComponent {
|
||||
case 'balance':
|
||||
return 'Balance Certificate';
|
||||
case 'form60':
|
||||
return 'Form 60 Declaration';
|
||||
return 'Form 97 Declaration';
|
||||
case 'form15gh':
|
||||
return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration';
|
||||
return 'Form 121 Declaration';
|
||||
case 'hypothecation':
|
||||
return 'Hypothecation Certificate';
|
||||
case 'noc':
|
||||
|
||||
@@ -11,11 +11,8 @@
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Form 15G & 15H</h2>
|
||||
<div class="cert-head-s">
|
||||
TDS-waiver declarations for FD / RD interest income —
|
||||
<strong>15G</strong> for individuals below 60 years, <strong>15H</strong> for senior citizens (60+).
|
||||
</div>
|
||||
<h2 class="cert-head-t">Form 121</h2>
|
||||
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,14 +23,14 @@
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<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 class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<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 class="cert-kpi cert-kpi--green">
|
||||
@@ -63,11 +60,11 @@
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Form</label>
|
||||
<label>Form Type</label>
|
||||
<select [(ngModel)]="kindFilter">
|
||||
<option value="">Both forms</option>
|
||||
<option value="15G">15G — Under 60</option>
|
||||
<option value="15H">15H — Senior citizens (60+)</option>
|
||||
<option value="">All Form 121</option>
|
||||
<option value="15G">Form 121 (Below 60 years)</option>
|
||||
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +89,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form</th>
|
||||
<th>Form Type</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Deposit Principal</th>
|
||||
@@ -115,7 +112,7 @@
|
||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
||||
>
|
||||
Form {{ formKind(r) }}
|
||||
Form 121 ({{ formKind(r) }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
||||
|
||||
@@ -146,9 +146,9 @@ export class Form15ghComponent implements OnInit {
|
||||
const kind = this.formKind(r);
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `Form ${kind} Declaration`,
|
||||
title: `Form 121 (${kind}) Declaration`,
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Form 60 Declaration</h2>
|
||||
<h2 class="cert-head-t">Form 97 Declaration</h2>
|
||||
<div class="cert-head-s">
|
||||
Income Tax Rule 114B — required for any customer transacting above the threshold who does
|
||||
<strong>not</strong> have a PAN on file.
|
||||
@@ -33,7 +33,7 @@
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<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 class="cert-kpi cert-kpi--amber">
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="cert-filter">
|
||||
<label>PAN status</label>
|
||||
<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="">All customers</option>
|
||||
</select>
|
||||
@@ -91,7 +91,7 @@
|
||||
<th>Trigger Transaction</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>PAN Status</th>
|
||||
<th>Form 60</th>
|
||||
<th>Form 97</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -146,7 +146,7 @@
|
||||
class="cert-icon-btn primary"
|
||||
[disabled]="r.hasPan"
|
||||
(click)="viewCertificate(r)"
|
||||
matTooltip="View / Print Form 60"
|
||||
matTooltip="View / Print Form 97"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
|
||||
@@ -121,7 +121,7 @@ export class Form60Component implements OnInit {
|
||||
|
||||
viewCertificate(r: Form60Record): void {
|
||||
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
|
||||
});
|
||||
return;
|
||||
@@ -139,14 +139,14 @@ export class Form60Component implements OnInit {
|
||||
|
||||
downloadCertificate(r: Form60Record): void {
|
||||
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
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: 'Form 60 Declaration',
|
||||
title: 'Form 97 Declaration',
|
||||
reference: `${r.customerId} · ${r.customerName}`,
|
||||
filename: `Form60-${r.customerId}.pdf`
|
||||
},
|
||||
|
||||
@@ -44,12 +44,12 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'letter-mgmt/form-60',
|
||||
component: Form60Component,
|
||||
data: { title: 'Form 60', breadcrumb: 'Form 60' }
|
||||
data: { title: 'Form 97', breadcrumb: 'Form 97' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/form-15gh',
|
||||
component: Form15ghComponent,
|
||||
data: { title: 'Form 15G & 15H', breadcrumb: 'Form 15G/H' }
|
||||
data: { title: 'Form 121', breadcrumb: 'Form 121' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/hypothecation-certificate',
|
||||
|
||||
+80
-33
@@ -6,29 +6,73 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (showData) {
|
||||
<div class="cv-tab-content">
|
||||
<div class="cv-action-bar">
|
||||
<mat-form-field appearance="outline" class="cv-search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.Filter by name' | translate }}"
|
||||
(keyup)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="cv-action-buttons">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="cv-btn cv-btn-approve"
|
||||
*mifosxHasPermission="'ACTIVATE_CLIENT'"
|
||||
(click)="approveClients()"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cv-tab-content">
|
||||
<div class="cv-action-bar">
|
||||
<div class="cv-search-box">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
placeholder="{{ 'labels.text.Filter by name' | translate }}"
|
||||
(keyup.enter)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="cv-quick-filters">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="cv-chip"
|
||||
[class.cv-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="cv-action-buttons">
|
||||
@if (selection.selected.length > 0) {
|
||||
<span class="cv-selected-count">{{ selection.selected.length }} selected</span>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="cv-btn cv-btn-approve"
|
||||
*mifosxHasPermission="'ACTIVATE_CLIENT'"
|
||||
[disabled]="!selection.hasValue()"
|
||||
(click)="approveClients()"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cv-empty">
|
||||
<div class="cv-spinner"></div>
|
||||
<p class="cv-empty__text">Loading pending clients…</p>
|
||||
</div>
|
||||
} @else if (loaded && !showData) {
|
||||
<div class="cv-empty">
|
||||
<div class="cv-empty__badge">
|
||||
<fa-icon icon="user-check"></fa-icon>
|
||||
</div>
|
||||
@if (searchTerm) {
|
||||
<p class="cv-empty__title">No matching clients</p>
|
||||
<p class="cv-empty__text">
|
||||
No pending client matches “{{ searchTerm }}”. Try
|
||||
<a class="cv-empty__link" (click)="applyFilter(''); filterInput.value = ''">clearing the search</a>.
|
||||
</p>
|
||||
} @else if (activePreset !== 'all') {
|
||||
<p class="cv-empty__title">No requests in this range</p>
|
||||
<p class="cv-empty__text">
|
||||
No client requests were submitted in this range. Try
|
||||
<a class="cv-empty__link" (click)="applyPreset('all')">All requests</a> instead.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="cv-empty__title">{{ 'labels.text.No pending loans for approval' | translate }}</p>
|
||||
<p class="cv-empty__text">Every client request has been reviewed — nothing waiting on you right now.</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (client of groupedClients | keyvalue; track client) {
|
||||
<h3 class="cv-group-title">{{ client.key }}</h3>
|
||||
@if (client.value.length > 0) {
|
||||
@@ -60,8 +104,8 @@
|
||||
{{ element.displayName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Number' | translate }}</th>
|
||||
<ng-container matColumnDef="clientId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
@@ -69,7 +113,7 @@
|
||||
[routerLink]="['../../clients', element.id]"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ element.accountNo }}
|
||||
{{ element.id }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="staff">
|
||||
@@ -94,12 +138,15 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!showData) {
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-user-check empty-icon"></i>
|
||||
<p class="empty-text">{{ 'labels.text.No pending loans for approval' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
<mat-paginator
|
||||
class="cv-paginator"
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
+207
-42
@@ -9,9 +9,14 @@
|
||||
@use '../../../shared/styles/list-layout' as *;
|
||||
|
||||
$brand: #1353a0;
|
||||
$brand-tint: #eaf1fb;
|
||||
$success: #16a34a;
|
||||
$success-tint: #ecfdf5;
|
||||
$border: #e2e8f0;
|
||||
$text: #0f172a;
|
||||
$text-muted: #64748b;
|
||||
$surface: #fff;
|
||||
$surface-muted: #f8fafc;
|
||||
|
||||
.cv-tab-content {
|
||||
padding: 16px 0;
|
||||
@@ -20,93 +25,252 @@ $text-muted: #64748b;
|
||||
.cv-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
}
|
||||
|
||||
.cv-search-field {
|
||||
.cv-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: $surface-muted;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-form-field-flex {
|
||||
height: 32px;
|
||||
fa-icon {
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: $text;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding: 4px 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
&:focus-within {
|
||||
background: $surface;
|
||||
border-color: $brand;
|
||||
box-shadow: 0 0 0 2px rgb(19 83 160 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.cv-quick-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mdc-notched-outline {
|
||||
.mdc-notched-outline__leading {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
.cv-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: 'DM Sans', Roboto, sans-serif;
|
||||
color: $text-muted;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 17px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
.mdc-notched-outline__trailing {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: $surface-muted;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-icon-prefix {
|
||||
padding: 0 6px 0 8px;
|
||||
color: $text-muted;
|
||||
}
|
||||
&--active {
|
||||
background: $brand;
|
||||
border-color: $brand;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(19 83 160 / 30%);
|
||||
|
||||
input {
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
background: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cv-action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cv-selected-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $brand;
|
||||
background: $brand-tint;
|
||||
border-radius: 14px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cv-btn {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
font-family: 'DM Sans', Roboto, sans-serif;
|
||||
letter-spacing: 0.1px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
border-color: $success;
|
||||
color: $success;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
&:hover:not([disabled]) {
|
||||
background: $success-tint;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
margin: 20px 0 8px;
|
||||
padding-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
color: $text-muted;
|
||||
margin: 22px 0 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $brand;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-group-table {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.cv-paginator {
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ===== Empty / loading state — scoped locally so it doesn't touch the shared
|
||||
// .empty-state used by every other checker-inbox tab. =====
|
||||
.cv-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cv-empty__badge {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $success-tint;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: $success;
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-empty__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.cv-empty__text {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
margin: 0;
|
||||
max-width: 360px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cv-empty__link {
|
||||
color: $brand;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $border;
|
||||
border-top-color: $brand;
|
||||
margin-bottom: 14px;
|
||||
animation: cv-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cv-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
@@ -121,5 +285,6 @@ $text-muted: #64748b;
|
||||
|
||||
.cv-action-buttons {
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
+109
-42
@@ -8,11 +8,9 @@
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import * as _ from 'lodash';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Dialog Imports */
|
||||
@@ -41,6 +40,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Checker-facing list of Clients pending approval.
|
||||
* Pagination, name search, and submitted-date filtering all happen server-side via the
|
||||
* standard /clients list API (offset/limit, displayName, fromDate/toDate) — mirrors
|
||||
* PendingClientEditsComponent's load()/onPageChange()/applyPreset() pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-approval',
|
||||
templateUrl: './client-approval.component.html',
|
||||
@@ -60,53 +65,122 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
KeyValuePipe,
|
||||
AccountsFilterPipe
|
||||
AccountsFilterPipe,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class ClientApprovalComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
export class ClientApprovalComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private dateUtils = inject(Dates);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private tasksService = inject(TasksService);
|
||||
|
||||
/** Grouped Clients Data */
|
||||
groupedClients: any;
|
||||
/** Checks to show the data */
|
||||
/** Current page of pending clients (from the server), grouped by office for display */
|
||||
groupedClients: any = {};
|
||||
/** True once the first page has loaded and it contains at least one client */
|
||||
showData = false;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
/** Row Selection Data — selection lives across the whole session, not just the current page */
|
||||
selection = new SelectionModel<any>(true, []);
|
||||
/** Batch Requests */
|
||||
batchRequests: any[];
|
||||
/** Datasource */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** Row Selection Data */
|
||||
selection: SelectionModel<any>;
|
||||
/** Displayed Columns */
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'name',
|
||||
'accountNumber',
|
||||
'clientId',
|
||||
'staff'
|
||||
];
|
||||
|
||||
/**
|
||||
* Retrieves the grouped client data from `resolve`.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Dialog} dialog MatDialog.
|
||||
* @param {Dates} dateUtils Date Utils.
|
||||
* @param {router} router Router.
|
||||
* @param {SettingsService} settingsService Settings Service.
|
||||
* @param {TasksService} tasksService Tasks Service.
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { groupedClientData: any }) => {
|
||||
this.groupedClients = _.groupBy(data.groupedClientData.pageItems, 'officeName');
|
||||
if (Object.keys(this.groupedClients).length) {
|
||||
this.showData = true;
|
||||
}
|
||||
this.dataSource = new MatTableDataSource(data.groupedClientData.pageItems);
|
||||
this.selection = new SelectionModel(true, []);
|
||||
});
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
|
||||
searchTerm = '';
|
||||
activePreset = 'all';
|
||||
readonly datePresets = [
|
||||
{ key: 'all', label: 'All requests' },
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' }
|
||||
];
|
||||
private fromDate: string | undefined;
|
||||
private toDate: string | undefined;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.tasksService
|
||||
.getGroupedClientsData(
|
||||
this.searchTerm.trim() || undefined,
|
||||
this.fromDate,
|
||||
this.toDate,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
const clients = res?.pageItems ?? [];
|
||||
this.groupedClients = _.groupBy(clients, 'officeName');
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.showData = this.totalRecords > 0 || Object.keys(this.groupedClients).length > 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.groupedClients = {};
|
||||
this.totalRecords = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = ''): void {
|
||||
this.searchTerm = filterValue;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = this.isoDate(today);
|
||||
this.toDate = this.isoDate(today);
|
||||
} else if (key === 'yesterday') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
this.fromDate = this.isoDate(yesterday);
|
||||
this.toDate = this.isoDate(yesterday);
|
||||
} else {
|
||||
this.fromDate = undefined;
|
||||
this.toDate = undefined;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
@@ -183,6 +257,7 @@ export class ClientApprovalComponent {
|
||||
activatedAccounts++;
|
||||
responseEle.body = JSON.parse(responseEle.body);
|
||||
if (selectedAccounts === activatedAccounts) {
|
||||
this.selection.clear();
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
@@ -190,18 +265,10 @@ export class ClientApprovalComponent {
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = '') {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetches data for the component
|
||||
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
|
||||
* Refetches the current page after a bulk approval.
|
||||
*/
|
||||
reload() {
|
||||
const url: string = this.router.url;
|
||||
this.router
|
||||
.navigateByUrl(`/checker-inbox-and-tasks`, { skipLocationChange: true })
|
||||
.then(() => this.router.navigate([url]));
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
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>
|
||||
Proposed Changes — Client
|
||||
<a [routerLink]="['/clients', data.clientId, 'general']" mat-dialog-close class="cecd-client-link">{{
|
||||
data.clientDisplayName
|
||||
}}</a>
|
||||
</h1>
|
||||
|
||||
<div mat-dialog-content class="cecd-content">
|
||||
<table class="cecd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Current Value</th>
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of data.rows; track row.key) {
|
||||
<tr>
|
||||
<td class="cecd-label">{{ row.label }}</td>
|
||||
<td class="cecd-current">{{ row.current }}</td>
|
||||
<td class="cecd-proposed">{{ row.value }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="cecd-empty">No changed fields detected.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (data.status && data.status !== 'PENDING' && data.checkerName) {
|
||||
<p class="cecd-resolution-note">
|
||||
<strong>{{ data.status === 'APPROVED' ? 'Approved' : 'Rejected' }} by</strong> {{ data.checkerName }}
|
||||
@if (data.checkedDate) {
|
||||
on {{ data.checkedDate }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (data.rejectReason) {
|
||||
<p class="cecd-reject-reason"><strong>Reject Reason:</strong> {{ data.rejectReason }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||
</mat-dialog-actions>
|
||||
+87
@@ -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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.cecd-client-link {
|
||||
color: #1e40af;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-content {
|
||||
min-width: 420px;
|
||||
max-width: 70vw;
|
||||
// Explicit scroll behavior rather than relying only on Material's default dialog-content
|
||||
// sizing — with many changed fields this keeps the dialog itself a fixed height (Close button
|
||||
// always visible) and scrolls just the table.
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cecd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
th {
|
||||
color: $text-muted;
|
||||
font-weight: 600;
|
||||
// Stays visible while scrolling through a long list of changed fields.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-label {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.cecd-current {
|
||||
color: #b91c1c;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #fca5a5;
|
||||
}
|
||||
|
||||
.cecd-proposed {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cecd-empty {
|
||||
color: $text-muted;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
.cecd-resolution-note {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.cecd-reject-reason {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface ClientEditCompareRow {
|
||||
key: string;
|
||||
label: string;
|
||||
current: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface ClientEditCompareDialogData {
|
||||
clientId: string;
|
||||
clientDisplayName: string;
|
||||
rows: ClientEditCompareRow[];
|
||||
status?: string;
|
||||
checkerName?: string;
|
||||
checkedDate?: string | null;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
/** Popup version of the Pending Client Edits "Compare" panel — shows the Field/Current/Proposed
|
||||
* diff in an in-app modal instead of an inline expansion below the table. */
|
||||
@Component({
|
||||
selector: 'mifosx-client-edit-compare-dialog',
|
||||
templateUrl: './client-edit-compare-dialog.component.html',
|
||||
styleUrls: ['./client-edit-compare-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class ClientEditCompareDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ClientEditCompareDialogComponent>>(MatDialogRef);
|
||||
data = inject<ClientEditCompareDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<!--
|
||||
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="pce-tab-content">
|
||||
<div class="pce-action-bar">
|
||||
<div class="pce-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
placeholder="Search by client name"
|
||||
(keyup.enter)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select class="pce-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="pce-preset-chip"
|
||||
[class.pce-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-date-range">
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">From</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="fromPicker"
|
||||
[(ngModel)]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onFromDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="fromPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</div>
|
||||
<span class="pce-date-range__sep">—</span>
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">To</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="toPicker"
|
||||
[(ngModel)]="toDate"
|
||||
[min]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onToDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="toPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="pce-btn pce-btn-refresh" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && edits.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||
<p class="empty-title">No {{ filterStatus | lowercase }} client edits</p>
|
||||
<p class="empty-text">
|
||||
No Client details edits found for this filter. Try
|
||||
<a (click)="applyPreset('all')" class="pce-client-link">All</a> dates, or a different status above.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="edits" class="modern-table">
|
||||
<ng-container matColumnDef="clientDisplayName">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<a [routerLink]="['/clients', edit.clientId, 'general']" class="pce-client-link">{{
|
||||
edit.clientDisplayName
|
||||
}}</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerName">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ edit.makerName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="submittedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.submittedDate) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<span class="status-badge" [ngClass]="statusClass(edit.status)">{{ edit.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||
</button>
|
||||
@if (edit.status === 'PENDING') {
|
||||
<button mat-stroked-button class="pce-btn pce-btn-approve" (click)="approve(edit)">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||
</button>
|
||||
<button mat-stroked-button class="pce-btn pce-btn-reject" (click)="reject(edit)">
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.pce-tab-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.pce-client-link {
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pce-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-filter-select {
|
||||
height: 34px;
|
||||
min-width: 120px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1353a0;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-fb-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pce-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pce-preset-chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid $border;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1353a0;
|
||||
border-color: #1353a0;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0f3f7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__sep {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 76px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px !important;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-view {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
margin-left: auto;
|
||||
|
||||
fa-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-reject {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
background: #fff;
|
||||
border: 1px dashed $border;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 42px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: $text-muted;
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||
|
||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||
* General step itself uses, so a checker sees "Male" / "Graduate" instead of a bare id. */
|
||||
const ID_FIELD_OPTIONS_KEY: Record<string, string> = {
|
||||
officeId: 'officeOptions',
|
||||
staffId: 'staffOptions',
|
||||
legalFormId: 'clientLegalFormOptions',
|
||||
salutationId: 'salutationOptions',
|
||||
genderId: 'genderOptions',
|
||||
maritalStatusId: 'maritalStatusOptions',
|
||||
educationLevelId: 'educationLevelOptions',
|
||||
degreeQualificationId: 'degreeQualificationOptions',
|
||||
religionId: 'religionOptions',
|
||||
socialCategoryId: 'socialCategoryOptions',
|
||||
occupationCategoryId: 'occupationCategoryOptions',
|
||||
occupationId: 'occupationOptions',
|
||||
pwdCategoryId: 'pwdCategoryOptions',
|
||||
savingsProductId: 'savingProductOptions'
|
||||
};
|
||||
|
||||
/** Code-value lists the General step fetches individually rather than via /clients/template. */
|
||||
const OPTIONS_KEY_TO_CODE_NAME: Record<string, string> = {
|
||||
maritalStatusOptions: 'MaritalStatus',
|
||||
educationLevelOptions: 'EducationLevel',
|
||||
degreeQualificationOptions: 'DegreeQualification',
|
||||
religionOptions: 'Religion',
|
||||
socialCategoryOptions: 'SocialCategory'
|
||||
};
|
||||
|
||||
const NATIONALITY_LABELS: Record<string, string> = {
|
||||
INDIAN: 'Indian',
|
||||
NRI: 'NRI',
|
||||
FOREIGN_NATIONAL: 'Foreign National',
|
||||
PIO: 'PIO',
|
||||
OCI: 'OCI'
|
||||
};
|
||||
|
||||
/**
|
||||
* Checker-facing list of Client details edits awaiting approval.
|
||||
* Mirrors PendingShareAccountEditsComponent's list + compare + approve/reject pattern — the
|
||||
* "compare" panel here generically diffs whatever keys are in the staged payload_json, since
|
||||
* Client has far more editable fields than Share Account's fixed handful.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pending-client-edits',
|
||||
templateUrl: './pending-client-edits.component.html',
|
||||
styleUrls: ['./pending-client-edits.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class PendingClientEditsComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/** Current page of pending edits, as returned by the server — pagination/date/search filtering
|
||||
* all happen server-side now (GET /clients/edit/pending?status&clientName&fromDate&toDate&
|
||||
* offset&limit), mirroring ChequebookRequestsComponent. */
|
||||
edits: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/** Populated from /clients/template + per-code-value lookups, used to resolve raw ids in the
|
||||
* compare panel into human-readable labels. Empty object until it loads — entries() just falls
|
||||
* back to showing the raw id/value until then. */
|
||||
private optionsByKey: Record<string, any[]> = {};
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'clientDisplayName',
|
||||
'makerName',
|
||||
'submittedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
searchText = '';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate!: Date;
|
||||
activePreset = 'today';
|
||||
readonly datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
readonly statusOptions = [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
this.maxDate = today;
|
||||
this.loadOptions();
|
||||
this.applyPreset('today');
|
||||
}
|
||||
|
||||
private loadOptions(): void {
|
||||
this.clientsService.getClientTemplate().subscribe((template: any) => {
|
||||
this.optionsByKey = { ...this.optionsByKey, ...(template || {}) };
|
||||
});
|
||||
Object.entries(OPTIONS_KEY_TO_CODE_NAME).forEach(
|
||||
([
|
||||
optionsKey,
|
||||
codeName
|
||||
]) => {
|
||||
this.clientsService.getCodeValuesByName(codeName).subscribe((options: any[]) => {
|
||||
this.optionsByKey[optionsKey] = options || [];
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.clientsService
|
||||
.getAllPendingClientEdits(
|
||||
this.filterStatus,
|
||||
this.searchText.trim() || undefined,
|
||||
this.isoDate(this.fromDate),
|
||||
this.isoDate(this.toDate),
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.edits = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.edits = [];
|
||||
this.totalRecords = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = ''): void {
|
||||
this.searchText = filterValue;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(today);
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'yesterday') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
this.fromDate = yesterday;
|
||||
this.toDate = yesterday;
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFromDateChange(): void {
|
||||
this.activePreset = '';
|
||||
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||
this.toDate = new Date(this.fromDate);
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onToDateChange(): void {
|
||||
this.activePreset = '';
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'status-badge--pending',
|
||||
APPROVED: 'status-badge--approved',
|
||||
REJECTED: 'status-badge--rejected'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
/** "Field / Current / Proposed" rows for whatever in the staged payload actually differs from
|
||||
* the point-in-time snapshot captured by the maker's browser when the edit page was opened
|
||||
* (stored as original_json — see buildOriginalValuesSnapshot() in client-edit-diff.util.ts).
|
||||
* The payload itself carries every General-step field (changed or not), so this filters it down
|
||||
* to only what the maker actually proposed to change — a TRUE historical diff, unlike comparing
|
||||
* against the LIVE client, which would show nothing once an edit is approved (by then current ==
|
||||
* proposed). Falls back to showing every field unfiltered when there's no original snapshot at
|
||||
* all (edits submitted before this column existed) rather than risk silently hiding a change. */
|
||||
entries(edit: any): { key: string; label: string; current: any; value: any }[] {
|
||||
if (!edit?.payloadJson) return [];
|
||||
let parsed: Record<string, any>;
|
||||
try {
|
||||
parsed = JSON.parse(edit.payloadJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let original: Record<string, any> | null = null;
|
||||
if (edit.originalJson) {
|
||||
try {
|
||||
original = JSON.parse(edit.originalJson);
|
||||
} catch {
|
||||
original = null;
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed).filter(
|
||||
(key) => !NON_COMPARABLE_KEYS.has(key) && parsed[key] !== null && parsed[key] !== undefined && parsed[key] !== ''
|
||||
);
|
||||
const rows: { key: string; label: string; current: any; value: any }[] = [];
|
||||
for (const key of keys) {
|
||||
const proposedRaw = parsed[key];
|
||||
const hasOriginal = !!original && Object.prototype.hasOwnProperty.call(original, key);
|
||||
const originalRaw = hasOriginal ? original![key] : undefined;
|
||||
|
||||
// Nested objects (e.g. clientNonPersonDetails for non-individual clients) aren't captured
|
||||
// in the flat original snapshot at all — always show them rather than guess at a diff.
|
||||
if (typeof proposedRaw === 'object' && !Array.isArray(proposedRaw)) {
|
||||
rows.push({ key, label: PENDING_EDIT_FIELD_LABELS[key] || key, current: null, value: proposedRaw });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changed = !original || !hasOriginal || this.normalize(originalRaw) !== this.normalize(proposedRaw);
|
||||
if (!changed) continue;
|
||||
rows.push({
|
||||
key,
|
||||
label: PENDING_EDIT_FIELD_LABELS[key] || key,
|
||||
current: hasOriginal ? this.resolveValue(key, originalRaw) : null,
|
||||
value: this.resolveValue(key, proposedRaw)
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private resolveValue(key: string, value: any): any {
|
||||
if (key === 'nationalityId') {
|
||||
return NATIONALITY_LABELS[value] || value;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
const optionsKey = ID_FIELD_OPTIONS_KEY[key];
|
||||
const options = optionsKey ? this.optionsByKey[optionsKey] : null;
|
||||
if (options?.length) {
|
||||
const match = options.find((o: any) => String(o.id) === String(value));
|
||||
if (match) return match.name || match.value || value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Normalizes a value (Fineract date array, formatted date string, boolean, id, plain value)
|
||||
* into a comparable string so a genuinely-unchanged field isn't shown as a false "change" just
|
||||
* because one side is an array-date and the other a formatted string. */
|
||||
private normalize(value: any): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value) && value.length >= 3 && value.every((v) => typeof v === 'number')) {
|
||||
return `${value[0]}-${value[1]}-${value[2]}`;
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'string') {
|
||||
const asDate = new Date(value);
|
||||
if (!isNaN(asDate.getTime()) && /[a-zA-Z]/.test(value)) {
|
||||
return `${asDate.getFullYear()}-${asDate.getMonth() + 1}-${asDate.getDate()}`;
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
viewCompare(edit: any): void {
|
||||
this.openCompareDialog(edit);
|
||||
}
|
||||
|
||||
private openCompareDialog(edit: any): void {
|
||||
this.dialog.open(ClientEditCompareDialogComponent, {
|
||||
width: '640px',
|
||||
data: {
|
||||
clientId: edit.clientId,
|
||||
clientDisplayName: edit.clientDisplayName,
|
||||
rows: this.entries(edit),
|
||||
status: edit.status,
|
||||
checkerName: edit.checkerName,
|
||||
checkedDate: edit.checkedDate ? this.formatDate(edit.checkedDate) : null,
|
||||
rejectReason: edit.rejectReason
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve(edit: any): void {
|
||||
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: 'Approve Client Edit',
|
||||
dialogContext: `Apply the pending details edit for client "${edit.clientDisplayName}"? This cannot be undone.`,
|
||||
type: 'basic'
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
this.clientsService.approveClientEdit(edit.clientId).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(edit: any): void {
|
||||
const formfields: FormfieldBase[] = [
|
||||
new InputBase({
|
||||
controlName: 'rejectReason',
|
||||
label: 'Reason for rejection',
|
||||
value: '',
|
||||
type: 'text',
|
||||
required: false
|
||||
})
|
||||
];
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
title: `Reject edit for client "${edit.clientDisplayName}"`,
|
||||
layout: { addButtonText: 'Reject' },
|
||||
formfields
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.data) {
|
||||
const rejectReason = response.data.value.rejectReason || '';
|
||||
this.clientsService.rejectClientEdit(edit.clientId, rejectReason).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,18 @@
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Share Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-client-edits']"
|
||||
routerLinkActive
|
||||
#pendingClientEdits="routerLinkActive"
|
||||
[active]="pendingClientEdits.isActive"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Client Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { TasksService } from '../tasks.service';
|
||||
|
||||
/**
|
||||
* Grouped Client Data data resolver.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetGroupedClientsData {
|
||||
private tasksService = inject(TasksService);
|
||||
|
||||
/**
|
||||
* Returns the grouped client data.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(): Observable<any> {
|
||||
return this.tasksService.getGroupedClientsData();
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
|
||||
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
||||
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
||||
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
||||
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
import { GetMakerCheckers } from './common-resolvers/getmakercheckers.resolver';
|
||||
import { GetGroupedClientsData } from './common-resolvers/getGroupedClientsData.resolver';
|
||||
import { GetOffices } from './common-resolvers/getOffices.resolver';
|
||||
import { GetLoansToBeApproved } from './common-resolvers/getLoansToBeApproved.resolver';
|
||||
import { GetLoansToBeDisbursed } from './common-resolvers/getLoansToBeDisbursed.resolver';
|
||||
@@ -69,10 +69,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'client-approval',
|
||||
component: ClientApprovalComponent,
|
||||
data: { title: 'Client Approval' },
|
||||
resolve: {
|
||||
groupedClientData: GetGroupedClientsData
|
||||
}
|
||||
data: { title: 'Client Approval' }
|
||||
},
|
||||
{
|
||||
path: 'loan-approval',
|
||||
@@ -152,6 +149,11 @@ const routes: Routes = [
|
||||
pendingShareAccountEditsData: GetPendingShareAccountEdits
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pending-client-edits',
|
||||
component: PendingClientEditsComponent,
|
||||
data: { title: 'Pending Client Edits' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
component: WithdrawalRequestsComponent,
|
||||
@@ -219,7 +221,6 @@ const routes: Routes = [
|
||||
exports: [RouterModule],
|
||||
providers: [
|
||||
GetMakerCheckers,
|
||||
GetGroupedClientsData,
|
||||
GetOffices,
|
||||
GetLoansToBeApproved,
|
||||
GetLoansToBeDisbursed,
|
||||
|
||||
@@ -48,10 +48,27 @@ export class TasksService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Grouped Clients Data
|
||||
* Get pending clients awaiting approval — pagination, name search and submitted-date
|
||||
* filtering all happen server-side via the standard /clients list API (offset/limit,
|
||||
* displayName, fromDate/toDate on submittedon_date), mirroring
|
||||
* ClientsService.getAllPendingClientEdits.
|
||||
*/
|
||||
getGroupedClientsData(): Observable<any> {
|
||||
const httpParams = new HttpParams().set('limit', '1000').set('status', 'pending');
|
||||
getGroupedClientsData(
|
||||
displayName?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let httpParams = new HttpParams()
|
||||
.set('status', 'pending')
|
||||
.set('orderBy', 'submittedOnDate')
|
||||
.set('sortOrder', 'DESC')
|
||||
.set('offset', offset)
|
||||
.set('limit', limit);
|
||||
if (displayName) httpParams = httpParams.set('displayName', displayName);
|
||||
if (fromDate) httpParams = httpParams.set('fromDate', fromDate);
|
||||
if (toDate) httpParams = httpParams.set('toDate', toDate);
|
||||
return this.http.get('/clients', { params: httpParams });
|
||||
}
|
||||
|
||||
|
||||
@@ -2084,8 +2084,8 @@
|
||||
"Floating Rates": "Floating Rates",
|
||||
"Floating rate": "Floating Rate",
|
||||
"Follow": "Follow",
|
||||
"Form 60": "Form 60",
|
||||
"Form 60 Declaration": "Form 60 Declaration",
|
||||
"Form 60": "Form 97",
|
||||
"Form 60 Declaration": "Form 97 Declaration",
|
||||
"Frequency": "Frequency",
|
||||
"Frequency Interval": "Frequency Interval",
|
||||
"Frequency Interval for compounding": "Frequency Interval for compounding",
|
||||
@@ -3983,7 +3983,7 @@
|
||||
"Organization available languages": "Organization can select a language here from the available languages here.",
|
||||
"Original Schedule": "Original Schedule",
|
||||
"Overdue Charges": "Overdue Charges",
|
||||
"PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.": "PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.",
|
||||
"PAN not provided. Please fill Form 60 as per Income Tax Rule 114B.": "PAN not provided. Please fill Form 97 as per Income Tax Rule 114B.",
|
||||
"Password Preferences": "Password Preferences",
|
||||
"Passwords": "Passwords",
|
||||
"Pay Charge": "Pay Charge",
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260723',
|
||||
'hash': 'ca85bae4'
|
||||
'version': '260729',
|
||||
'hash': 'c1fc95d9'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
/* tslint:enable */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user