Compare commits

...
19 Commits
Author SHA1 Message Date
nitesh 8c7d8287f9 Merge branch 'uate' into dev — sync branches to same level
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-04 11:35:08 +05:30
Anshul Rathore a560cd6533 feat: add Welcome Kit dispatch dialog and Share Holders view
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-07-31 14:06:27 +05:30
keshav 0bf337b962 Merge branch 'uate' of https://git.lincpay.tech/CBSNexora/cbsnexora into uate 2026-07-31 11:00:14 +05:30
keshav 1daa12a117 kyc risk and AML & Compliance Checks automate on client creation 2026-07-31 10:58:58 +05:30
nitesh f687e1808d fix(http): stop surfacing background/enrichment-fetch failures as error toasts
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Two related cases where ErrorHandlerInterceptor's global toast fired even
though the calling component already recovers gracefully on its own:

- GET /kyc/risk-rules 404ing (e.g. not yet deployed on a given backend)
  popped a raw "Http failure response ... 404" toast on the Create Client
  page, even though client-general-step already falls back to an empty
  rule set and a default risk category when this call fails.

- The Clients list page fires several per-row background calls
  (client data/KYC/address, for every visible row) plus the main search —
  each already had its own catchError/error handling, but the
  interceptor's 500 branch had no silencing path at all (only 403/404 did,
  and only for a fixed set of known-optional URLs), so any backend
  hiccup there spammed "Internal Server Error" toasts on top of an
  otherwise-working page.

Added a SILENT_HTTP_ERROR HttpContextToken so a caller can opt a specific
request out of the toast for every status code (not just 403/404) when it
already handles failure itself — threaded as an optional `silent` param
through ClientsService's searchByText/getClientData/getClientKyc/
getClientAddressData (default false, so no other of their ~50+ call sites
change behavior) and set from ClientsComponent's list-page calls. The
risk-rules endpoint was simpler: just added to the interceptor's existing
URL-based silent-request list alongside client images/KYC/pending-edits.
2026-07-30 13:41:28 +05:30
nitesh 186a9057a1 feat(clients): confirm linked Client ID + details after adding a family member
The "Add Member" dialog closed silently on submit, giving no
acknowledgement of what got recorded — most notably the linked client's
own CBS Client ID when the member was linked to an existing client rather
than entered manually. Added a confirmation popup showing Client ID (when
linked), name, relationship, DOB and dependent status right after submit.
2026-07-30 13:38:10 +05:30
nitesh 387bd060e1 feat(clients): draw-signature option for Specimen Signature, drop its webcam capture
DrawSignatureDialogComponent (a mouse/touchpad signature pad) already
existed and was used from the client profile view, but wasn't wired into
the KYC step used during client creation/edit — a stale comment there
even referred to a "draw-pad" option as if it were already present.
Wired it in as a third capture method alongside Choose & Crop, then
removed Use Webcam from the signature box specifically (kept for Client
Photograph) per follow-up feedback — a signature is drawn or scanned, not
photographed.

Refactored the file-picker/webcam/draw-pad paths' duplicated stage+upload
logic into shared applySignatureFile()/uploadSignatureFile() helpers.
2026-07-30 12:28:08 +05:30
nitesh 29f1cdc646 fix(clients): Aadhaar show/hide icon spilling past the field border in KYC step
.toggle-vis-btn shrinks the visible mat-icon-button to 28px, but Material
still reserves its default ~48px invisible touch-target/ripple centered
on the button. Since the suffix container doesn't clip overflow, that
ripple rendered past the input's own border on hover/focus, making the
eye icon look like it was sitting outside/on top of the field edge.
Constrained the touch-target and ripple to match the shrunk button.
2026-07-30 10:32:33 +05:30
nitesh 1efb520009 feat(tasks): backend-driven pagination, search and date filters for Client Approval
Client Approval previously fetched up to 1000 pending clients via a route
resolver and did name-search/sorting client-side (the search box was
actually dead — it set a MatTableDataSource filter that the grouped-table
view never read). Reworked to mirror PendingClientEditsComponent: real
mat-paginator against /clients (offset/limit), server-side displayName
search, orderBy=submittedOnDate for latest-first, and All/Today/Yesterday
presets backed by the new fromDate/toDate params. Dropped the now-unused
GetGroupedClientsData resolver.

Also: Account Number column -> Client ID (element.id), disabled the
Approve button with a "N selected" indicator when nothing's checked, and
a redesigned action bar / empty-state (context-aware message for
zero-results-from-search vs. zero-results-from-date-filter vs. truly
nothing pending) — plus the plain-div search box and matched heights
across chips/search/button, replacing an earlier mat-form-field version
that Material's own outlined-field box model kept misaligning against the
pill buttons.
2026-07-30 10:31:55 +05:30
nitesh 4ea833ff49 feat(clients): cite UAPA Sec. 51-A / RBI KYC Direction and list-currency date in sanctions dialog
The sanctions verification dialog said which lists a client was screened
against but not the regulatory basis for doing so, nor when that list
data was last refreshed. Added a regulatory-basis note and a "List Data
As Of" row (sourced from the backend's newly-exposed listDataAsOf) to
both the cleared and match-found panels.
2026-07-30 10:30:45 +05:30
nitesh 5dc284bd1c fix(clients): address-proof date fields use calendar picker, Pension Payment Order gets one too
Both the address-proof upload dialog and the permanent-address step used a
native `type="date"` input for the issue/statement date, so no calendar
popup was available. Switched both to Angular Material's datepicker, kept
the client-india-address-step round-trip (payload -> saved description ->
re-parsed on edit) working with the Dates util now that the control holds
a Date object instead of a raw string.

Pension Payment Orders was also the only "Financial & Other" address-proof
type missing requiresDate, so selecting it never showed a date field at
all — added the flag and moved Monthly/Yearly Turnover under the "Other
Income" heading alongside Agriculture/Non-Agriculture Income.
2026-07-30 10:30:00 +05:30
niteshandClaude Sonnet 5 56f13ade67 fix(clients): latest-identifier ordering for KYC docs, show client ID in list
Repeat KYC document uploads create a new client_identifier row rather than
updating in place, so loadExistingKycIdentifier() must prefer the newest
matching row instead of the first one returned by the API. Also swap the
Clients list "Account No." column to show the client's numeric ID instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:32:41 +05:30
nitesh dbd160e7e7 Merge remote-tracking branch 'origin/uate' into uate 2026-07-29 10:29:55 +05:30
niteshandClaude Sonnet 5 468eaa96ab feat(clients): maker-checker for client edits (frontend)
- New "Pending Client Edits" screen under Checker Inbox & Tasks: server-driven pagination,
  date range + Today/Yesterday/All presets, status filter (Pending/Approved/Rejected), search —
  mirrors ChequebookRequestsComponent's architecture.
- Compare dialog diffs the maker's proposed values against a point-in-time "before" snapshot
  (client-edit-diff.util.ts) captured when the edit page was opened, so only genuinely changed
  fields show — works correctly for Pending, Approved, and Rejected edits alike, since it no
  longer depends on comparing against the live (possibly already-updated) client record.
- edit-client.component.ts: General tab save now submits for approval instead of applying
  immediately, shows a pending-approval banner, and blocks a second submission while one's
  outstanding.
- error-handler.interceptor.ts: added the new /clients/{id}/edit/pending check to the existing
  "expected 403/404, don't alert" allowlist (same pattern already used for client images/KYC).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 10:27:45 +05:30
keshav f682cb17f6 CTR/STR report and Risk Category auto mark on bases of person occupation and anual salary enter 2026-07-29 10:21:02 +05:30
niteshandClaude Sonnet 5 79fedb66e5 fix(clients): minor-age threshold to 18, senior/PWD doc reload, in-app document preview dialog
- Raise isMinor threshold from age <10 to <18 (legal definition), cascading correctly to
  guardian requirement, KYC document filtering, and Education Level/Degree Qualification
  visibility (now shown for all ages, not hidden for minors).
- Load and display previously-uploaded Senior Citizen proof / PWD certificate documents on
  edit (view/replace/remove), instead of always showing an empty upload prompt.
- Fix seniorPwdDocsComplete to accept an existing certificate, not just a freshly-picked file.
- Add DocumentPreviewDialogComponent: a reusable in-app modal (image/PDF/download) used in
  place of window.open() for saved KYC/address/senior-citizen/PWD documents — avoids popup
  blockers (fired only after the async download resolved) and Chrome/Edge's failure to paint
  large base64 data: URLs on first open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:49:53 +05:30
niteshandClaude Sonnet 5 c1fc95d9b7 fix(clients): minor-specific KYC document types (Birth Certificate/School ID) never appeared
filterDocTypesForMinor() only ever removes entries. The initial (pre-DOB, isMinor=false) seed
already stripped Birth Certificate/School ID Card out of kycDocumentTypes, so the isMinor
ngOnChanges handler re-filtering that same shortened list could never bring them back once the
client turned out to be a minor. Rebuild from the full defaultDocumentTypes source instead
(matching how the initial seed itself is built) whenever isMinor changes and the backend KYC
template hasn't already taken over. Also gives School ID Card the "Government-issued ID"
category it was missing, so it groups with Aadhaar/Passport/Birth Certificate instead of "Other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:56:30 +05:30
nitesh b422ae6600 Merge remote-tracking branch 'origin/uate' into uate 2026-07-28 12:37:59 +05:30
niteshandClaude Sonnet 5 5fd7e1890e feat(clients): require Mother + Father/Caretaker as family members before activation
- Add hasMother/hasFather/hasCaretaker/familyRelationshipRulesMet to
  ClientFamilyMembersStepComponent, matched against the RELATIONSHIP code value label.
- Show two independent inline error banners (never merged into one message) when Mother, or
  both Father and Caretaker, are missing.
- Gate "Save & Next" and final Submit on this rule in both the create and edit client flows —
  the edit flow previously treated the Family tab as unconditionally optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:36:30 +05:30
66 changed files with 5316 additions and 494 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* AML transaction-monitoring API: live alerts raised by the rule engine, the
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
* client's risk-category change history.
*/
@Injectable({
providedIn: 'root'
})
export class AmlService {
private http = inject(HttpClient);
// ═══ Alerts (AML Officer) ═══
getAlerts(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/alerts', { params });
}
/** disposition: 'WHITELIST' | 'ESCALATE' */
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases');
}
getStrCase(strCaseId: number): Observable<any> {
return this.http.get(`/aml/str-cases/${strCaseId}`);
}
getWeeklyDigest(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
}
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
}
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
concludeStrCase(
strCaseId: number,
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
investigationNotes: string,
sourceOfFundsNotes: string
): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
conclusion,
investigationNotes,
sourceOfFundsNotes
});
}
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
}
// ═══ CTR reports (mechanical, threshold-based) ═══
getCtrReports(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports');
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
}
// ═══ Client risk-category history ═══
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
}
@@ -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">
@@ -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;
@@ -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);
}
@@ -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')) {
@@ -365,7 +365,7 @@
}
</div>
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="guardian-section">
<!-- Header bar -->
@@ -1035,69 +1035,67 @@
Education &amp; 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">
@@ -1234,38 +1232,6 @@
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</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">&#8377;</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="coins" class="subsection-icon"></fa-icon>
Other Income
@@ -1305,6 +1271,38 @@
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</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">&#8377;</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
@@ -1322,6 +1320,8 @@
<fa-icon icon="file-upload"></fa-icon>
@if (seniorCitizenProofFile) {
<span>{{ seniorCitizenProofFile.name }}</span>
} @else if (existingSeniorCitizenProofDoc) {
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
} @else {
<span>Upload Senior Citizen Proof (Age / ID Proof) &mdash; optional</span>
}
@@ -1330,6 +1330,13 @@
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
} @else if (existingSeniorCitizenProofDoc) {
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@@ -1354,12 +1361,16 @@
<div class="full-width doc-upload-field">
<label
class="doc-upload-label"
[class.doc-upload-label--missing]="seniorPwdDocsTouched && !pwdCertificateFile"
[class.doc-upload-label--missing]="
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
<fa-icon icon="file-upload"></fa-icon>
@if (pwdCertificateFile) {
<span>{{ pwdCertificateFile.name }}</span>
} @else if (existingPwdCertificateDoc) {
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
} @else {
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
}
@@ -1368,8 +1379,15 @@
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
} @else if (existingPwdCertificateDoc) {
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
@if (seniorPwdDocsTouched && !pwdCertificateFile) {
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
<span class="doc-upload-error">Required when PWD is checked</span>
}
</div>
@@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
@@ -98,10 +99,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
seniorCitizenProofFile: File | null = null;
pwdCertificateFile: File | null = null;
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
existingPwdCertificateDoc: { id: string; name: string } | null = null;
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
pwdCategoryOptions: any[] = [];
riskRules: any[] = [];
/** Set true after a save attempt so the UI can highlight a missing required document */
seniorPwdDocsTouched = false;
@@ -111,7 +118,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
get seniorPwdDocsComplete(): boolean {
const pwdOk =
!this.createClientForm.get('isPwd')?.value ||
(!!this.pwdCertificateFile && !!this.createClientForm.get('pwdCategoryId')?.value);
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
!!this.createClientForm.get('pwdCategoryId')?.value);
return pwdOk;
}
@@ -141,6 +149,93 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.pwdCertificateFile = null;
}
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
* already on file instead of an empty upload prompt. */
private loadExistingSeniorPwdDocuments(): void {
const clientId = this.clientData?.id;
if (!clientId) {
return;
}
this.clientService.getClientDocuments(clientId.toString()).subscribe({
next: (documents: any[]) => {
const seniorDoc = documents
?.filter((d: any) => d.name === 'seniorCitizenProof')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (seniorDoc?.id) {
this.existingSeniorCitizenProofDoc = {
id: seniorDoc.id.toString(),
name: seniorDoc.fileName || 'Senior Citizen Proof'
};
}
const pwdDoc = documents
?.filter((d: any) => d.name === 'pwdCertificate')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (pwdDoc?.id) {
this.existingPwdCertificateDoc = {
id: pwdDoc.id.toString(),
name: pwdDoc.fileName || 'Disability Certificate'
};
}
},
error: () => {}
});
}
viewExistingSeniorCitizenProof(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingSeniorCitizenProofDoc) {
return;
}
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
next: (blob: any) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: {
blob,
title: this.existingSeniorCitizenProofDoc?.name,
fileName: this.existingSeniorCitizenProofDoc?.name
}
}),
error: () => {}
});
}
removeExistingSeniorCitizenProof(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingSeniorCitizenProofDoc) {
return;
}
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
next: () => (this.existingSeniorCitizenProofDoc = null),
error: () => {}
});
}
viewExistingPwdCertificate(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingPwdCertificateDoc) {
return;
}
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
next: (blob: any) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
}),
error: () => {}
});
}
removeExistingPwdCertificate(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingPwdCertificateDoc) {
return;
}
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
next: () => (this.existingPwdCertificateDoc = null),
error: () => {}
});
}
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -203,7 +298,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
/** Calculated age detail string (Y/M/D) */
calculatedAgeDetail: string | null = null;
/** Whether client is a minor (age < 10) */
/** Whether client is a minor (age < 18) */
isMinor = false;
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
isSeniorCitizen = false;
@@ -452,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 || [];
@@ -600,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({
@@ -980,7 +1088,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
this.ageBreakdown = { years, months, days };
const wasMinor = this.isMinor;
this.isMinor = years < 10;
this.isMinor = years < 18;
this.isSeniorCitizen = years >= 60;
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
@@ -2025,6 +2133,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return generalDetails;
}
get calculatedRisk(): {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} {
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
const occId = this.createClientForm.get('occupationCvId')?.value;
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Skip transaction/AML rules that have no KYC criteria
if (
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
) {
continue;
}
// Match legal form
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
continue;
}
// Match occupation category
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
continue;
}
// Match occupation
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
continue;
}
// Match monthly income ranges
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
continue;
}
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
continue;
}
// Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
continue;
}
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
continue;
}
// Match turnover ranges
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
continue;
}
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
continue;
}
// All matched!
return {
category: rule.riskCategory,
reason: rule.reason,
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
dormantFlag: rule.dormantFlag || 'ACTIVE'
};
}
}
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
return {
category: 1,
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
}
/**
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
<strong>UN Security Council</strong> &amp;
<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;
@@ -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>
@@ -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,6 +41,8 @@ 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 = '';
@@ -46,6 +51,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
/** 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;
@@ -279,8 +288,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = this.filterAddressProofsForMinor(
(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;
@@ -306,6 +315,26 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
);
}
/** 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);
@@ -386,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
};
}
@@ -422,6 +452,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
}
// 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();
}
}
/**
@@ -525,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()) });
}
}
@@ -633,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.
}
@@ -1049,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">
@@ -1086,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
@@ -1187,6 +1251,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
Override Auto-calculated AML &amp; Compliance Checks
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>AML Check Status</mat-label>
<mat-select required formControlName="amlCheckStatus">
@@ -1417,8 +1487,8 @@
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; 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) {
@@ -314,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 */
@@ -2257,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',
@@ -69,6 +71,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
@Input() calculatedRisk: {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} | null = null;
today = new Date();
kycId: string = '';
@@ -169,6 +180,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ 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 }[]> = {
HUF: [
@@ -249,6 +271,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;
@@ -440,6 +465,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
amlCheckStatus: [
'CLEAR',
@@ -487,6 +514,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
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();
@@ -494,20 +529,62 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
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);
}
}
});
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
const amlControls = [
this.kycForm.get('amlCheckStatus'),
this.kycForm.get('bannedEntityMatch'),
this.kycForm.get('willfulDefaulter'),
this.kycForm.get('ecgcSalFlag'),
this.kycForm.get('dormantFlag')
];
if (allowed) {
if (!this.readOnly) {
amlControls.forEach((ctrl) => ctrl?.enable());
}
} else {
amlControls.forEach((ctrl) => ctrl?.disable());
this.applyAutoCalculatedAmlFlags();
}
});
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable();
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable();
this.kycForm.get('bannedEntityMatch')?.disable();
this.kycForm.get('willfulDefaulter')?.disable();
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
}
/** Seed the document-type picker from defaults, filtered by the current isMinor input.
* Only seeds when the list is empty — the template API (loadKycTemplate) takes over once
* a clientId is available. */
/** 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.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
@@ -525,6 +602,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.reset({
hasPan: true,
riskCategory: 1,
allowRiskOverride: false,
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
@@ -545,6 +623,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
this.existingKycIdentifierId = null;
this.riskCalculationReason = '';
this.showRiskRulesTable = false;
this.existingKycDocuments = [];
this.photoSigTouched = false;
this.panVerifyStatus = 'NONE';
@@ -597,21 +677,70 @@ 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 (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
}
}
}
if (changes['calculatedRisk']) {
const newRisk = changes['calculatedRisk'].currentValue;
if (newRisk) {
this.riskCalculationReason = newRisk.reason;
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
const riskControl = this.kycForm.get('riskCategory');
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
riskControl.setValue(newRisk.category);
riskControl.markAsDirty();
}
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
if (!allowAmlOverride) {
this.applyAutoCalculatedAmlFlags(newRisk);
}
} else {
this.riskCalculationReason = '';
}
}
if (changes['constitutionMeta'] && this.isNonIndividual) {
this.updateDocTypesForConstitution();
}
if (changes['isMinor']) {
// 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('');
}
const filtered = this.filterDocTypesForMinor(this.kycDocumentTypes);
if (filtered.length !== this.kycDocumentTypes.length) {
this.kycDocumentTypes = filtered;
// 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 && !filtered.some((d) => d.name === currentName)) {
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
this.kycForm.get('kycDocumentName')?.setValue('');
}
this.rebuildKycDocPicker();
@@ -687,30 +816,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 */
}
@@ -801,6 +917,14 @@ 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,
allowAmlOverride: this.calculatedRisk
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
: false,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
@@ -886,12 +1010,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();
@@ -1019,13 +1148,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
});
@@ -1585,20 +1712,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, {
@@ -1608,42 +1721,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;
@@ -1668,7 +1790,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;
@@ -1737,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.form15Applicable
);
}
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
if (!risk) return;
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
this.kycForm.get('amlCheckStatus')?.markAsDirty();
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
this.kycForm.get('willfulDefaulter')?.markAsDirty();
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
this.kycForm.get('dormantFlag')?.markAsDirty();
}
}
/**
@@ -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>
@@ -410,6 +410,9 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
/** Custom Services */
import { ClientsService } from '../clients.service';
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
case 'Delete Image':
this.deleteProfileImage();
break;
case 'Welcome Kit':
this.dispatchWelcomeKit();
break;
case 'Create Standing Instructions':
const createStandingInstructionsQueryParams: any = {
officeId: this.clientViewData.officeId,
@@ -455,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
}
});
}
/**
* Opens the Welcome Kit Dispatch dialog.
*/
private dispatchWelcomeKit() {
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
// the form was successfully submitted
this.reload();
}
});
}
}
@@ -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>
}
@@ -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;
}
}
@@ -0,0 +1,94 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
</div>
</div>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button
mat-raised-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
Dispatch
</button>
</mat-dialog-actions>
@@ -0,0 +1,59 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
p {
margin: 6px 0;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&.status-dispatched {
background-color: #1976d2;
}
&.status-in_transit {
background-color: #f57c00;
}
&.status-delivered {
background-color: #388e3c;
}
&.status-returned {
background-color: #d32f2f;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
}
.full-width {
width: 100%;
}
@@ -0,0 +1,347 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ClientsService } from '../../../clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
@Component({
selector: 'mifosx-welcome-kit-dialog',
templateUrl: './welcome-kit-dialog.component.html',
styleUrls: ['./welcome-kit-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
isLoading = true;
welcomeKitsData: any = null;
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
});
deliveryModes = [
'POSTMAN',
'COURIER',
'OTHER'
];
constructor() {
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
if (mode === 'OTHER') {
otherControl?.setValidators([Validators.required]);
} else {
otherControl?.clearValidators();
}
otherControl?.updateValueAndValidity();
});
}
ngOnInit(): void {
this.fetchData();
}
fetchData() {
this.isLoading = true;
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
next: (response: any) => {
this.welcomeKitsData = response;
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
this.fetchPrimaryAddress();
} else {
this.isLoading = false;
}
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
}
});
}
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
});
}
},
error: () => {
this.isLoading = false;
}
});
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
}
return [];
}
submit() {
if (this.dispatchForm.invalid) {
this.dispatchForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const payload = {
deliveryAddress: formValue.deliveryAddress,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
}
});
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
return address;
}
downloadPdf(dispatch: any) {
const doc = new jsPDF();
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
const address = this.getDisplayAddress(dispatch);
const mode =
dispatch.deliveryMode === 'OTHER'
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
: dispatch.deliveryMode;
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const primaryColor: [number, number, number] = [
31,
73,
125
]; // Navy Blue
const grayColor: [number, number, number] = [
100,
100,
100
];
const lightGray: [number, number, number] = [
230,
230,
230
];
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// --- 1. Header Banner ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
// Decorative circles
doc.setFillColor(255, 255, 255);
doc.circle(pageWidth - 20, 15, 3, 'F');
doc.circle(pageWidth - 30, 25, 2, 'F');
doc.circle(pageWidth - 10, 30, 1.5, 'F');
doc.setFontSize(28);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 20, 22);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 22, 30);
// --- 2. Title ---
let currentY = 55;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
currentY += 12;
// --- 3. Date Line ---
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
const today = this.datePipe.transform(new Date(), 'longDate');
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
currentY += 15;
// --- 4. Greeting ---
doc.text(`Dear ${clientName},`, 20, currentY);
currentY += 10;
// --- 5. Opening welcome paragraph ---
const p1 =
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
doc.text(splitP1, 20, currentY);
currentY += splitP1.length * 6 + 8;
// --- 6. Short paragraph about the company ---
const p2 =
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
doc.text(splitP2, 20, currentY);
currentY += splitP2.length * 6 + 8;
// --- 7. Transition line before details ---
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
currentY += 8;
// --- 8. Details Section ---
autoTable(doc, {
startY: currentY,
theme: 'plain',
margin: { left: 20, right: 20 },
styles: { fontSize: 11, cellPadding: 6, textColor: [
0,
0,
0
] },
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 50 },
1: { cellWidth: 'auto' }
},
didDrawCell: (data) => {
if (data.row.index >= 0) {
doc.setDrawColor(...lightGray);
doc.setLineWidth(0.2);
doc.line(
data.cell.x,
data.cell.y + data.cell.height,
data.cell.x + data.cell.width,
data.cell.y + data.cell.height
);
}
},
body: [
[
'Delivery Address',
address
],
[
'Delivery Mode',
mode
],
[
'Tracking ID',
trackingId
],
[
'Status',
status
],
[
'Dispatched On',
dispatchedOn
]
]
});
// --- 9. Closing paragraph ---
currentY = (doc as any).lastAutoTable.finalY + 12;
const p3 =
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
doc.text(splitP3, 20, currentY);
currentY += splitP3.length * 6 + 12;
// --- 10. Sign-off ---
doc.text('Warm regards,', 20, currentY);
currentY += 7;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 20, currentY);
// --- 11. Footer ---
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
let footerY = currentY + 30;
if (footerY < pageHeight - 30) {
footerY = pageHeight - 30;
}
doc.setDrawColor(...primaryColor);
doc.setLineWidth(0.3);
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(...grayColor);
const timestamp = this.datePipe.transform(new Date(), 'medium');
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
}
@@ -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>
+3 -5
View File
@@ -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 -->
+13 -4
View File
@@ -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) {
+73 -9
View File
@@ -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([])));
@@ -894,4 +950,12 @@ export class ClientsService {
submitClientForm60(clientId: string, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/form60`, data);
}
getWelcomeKits(clientId: string | number): Observable<any> {
return this.http.get(`/clients/${clientId}/welcomekit`);
}
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
}
@@ -76,6 +76,7 @@
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
@@ -148,6 +149,7 @@
<mifosx-client-india-address-step
#clientIndiaAddress
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
</div>
@@ -240,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;
@@ -275,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.';
}
@@ -571,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 {
@@ -152,6 +165,7 @@
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
</div>
@@ -208,6 +222,7 @@
#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();
}
}
+40
View File
@@ -81,6 +81,7 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
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: {
@@ -144,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.'
}
};
@@ -392,6 +392,22 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'clerk');
}
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
isPrincipalOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'principal officer');
}
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
isAmlOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'aml officer');
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
+24 -2
View File
@@ -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)) {
+17
View File
@@ -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);
+15 -9
View File
@@ -1121,7 +1121,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
@@ -1167,10 +1167,6 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
</button>
<button mat-menu-item routerLink="/fiu" tabindex="0">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
Unit (FIU-IND)
</button>
</div>
<!-- Investment -->
<div class="deposit-menu-group">
@@ -1568,8 +1564,13 @@
<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')"
routerLink="/fiu"
(click)="rbiMenuOpen = false"
>
<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
@@ -1664,8 +1665,13 @@
>
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('AML')"
routerLink="/rbi/aml"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a>
</div>
<!-- OSS Reports -->
+221 -2
View File
@@ -129,9 +129,130 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR — Cash Transaction Report</h3>
<h3>Live Cash Alerts</h3>
<p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
@@ -221,9 +342,107 @@
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR — Suspicious Transaction Report</h3>
<h3>Live STR Cases</h3>
<p>
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
working days
</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th class="ta-c">Cumulative Cases</th>
<th>Assigned PO</th>
<th>Conclusion</th>
<th>FIU Due Date</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
{{ sc.cumulativeStrCaseCountForClient }}
</td>
<td>{{ sc.assignedPoUsername || '—' }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ sc.fiuDueDate || '—' }}</td>
<td>
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<div class="form-row conclusion-row">
<label>
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Not suspicious (whitelist)
</label>
<label>
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
Suspicious (file STR)
</label>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</div>
</div>
}
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet STR XML Export Template</h3>
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
+117
View File
@@ -579,6 +579,41 @@
background: var(--f-red-l);
color: var(--f-red);
}
&.status-raised {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.status-under-review {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.status-whitelisted,
&.status-concluded-clear,
&.status-generated,
&.status-submitted {
background: var(--f-green-l);
color: var(--f-green);
}
&.status-escalated,
&.status-open,
&.status-concluded-suspicious {
background: var(--f-red-l);
color: var(--f-red);
}
&.status-filed {
background: var(--f-purple-l);
color: var(--f-purple);
}
&.status-draft {
background: var(--f-g100);
color: var(--f-g500);
}
}
.risk-pill {
@@ -892,3 +927,85 @@
grid-template-columns: 1fr;
}
}
.selected-row {
background: var(--f-blue-l);
}
.escalation-warning {
color: var(--f-red);
font-weight: 700;
}
.escalation-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 10px;
background: var(--f-amber-l);
color: var(--f-amber);
font-size: 11px;
font-weight: 600;
}
.str-case-detail {
border: 1px solid var(--f-g200);
border-radius: 10px;
padding: 16px;
background: #fff;
margin-bottom: 16px;
}
.case-form {
display: flex;
flex-direction: column;
gap: 12px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
.conclusion-row {
flex-direction: row;
gap: 24px;
label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
}
.file-str-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed var(--f-g300);
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
+126 -2
View File
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import {
CtrTransaction,
CTR_TXNS,
@@ -33,7 +35,9 @@ import {
FormsModule
]
})
export class FiuComponent {
export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private authenticationService = inject(AuthenticationService);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -57,6 +61,126 @@ export class FiuComponent {
toastVisible = false;
private toastTimer: any;
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
amlAlerts: any[] = [];
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
concludeSourceOfFundsNotes = '';
fileReferenceNo = '';
get isPrincipalOfficer(): boolean {
return this.authenticationService.isPrincipalOfficerUser();
}
get isAmlOfficer(): boolean {
return this.authenticationService.isAmlOfficerUser();
}
ngOnInit(): void {
this.loadAmlAlerts();
this.loadAmlCtrReports();
this.loadAmlStrCases();
}
loadAmlAlerts(): void {
this.amlService
.getAlerts(this.amlAlertStatusFilter || undefined)
.subscribe((data) => (this.amlAlerts = data || []));
}
loadAmlCtrReports(): void {
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
}
loadAmlStrCases(): void {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
}
whitelistAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} whitelisted`);
this.loadAmlAlerts();
});
}
escalateAlert(alert: any): void {
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
return;
}
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
next: () => {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports();
},
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
});
}
selectStrCase(strCase: any): void {
this.selectedStrCase = strCase;
this.concludeConclusion = 'NOT_SUSPICIOUS';
this.concludeInvestigationNotes = strCase.investigationNotes || '';
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
this.fileReferenceNo = '';
}
closeStrCaseDetail(): void {
this.selectedStrCase = null;
}
concludeStrCase(): void {
if (!this.selectedStrCase) {
return;
}
this.amlService
.concludeStrCase(
this.selectedStrCase.id,
this.concludeConclusion,
this.concludeInvestigationNotes,
this.concludeSourceOfFundsNotes
)
.subscribe((updated) => {
this.toast(
this.concludeConclusion === 'SUSPICIOUS'
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
: `STR case #${updated.id} concluded not suspicious`
);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
fileStrCase(): void {
if (!this.selectedStrCase || !this.fileReferenceNo) {
return;
}
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
this.selectedStrCase = updated;
this.loadAmlStrCases();
});
}
amlStatusClass(status: string): string {
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
}
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
this.activeTab = tab;
}
+4
View File
@@ -40,6 +40,10 @@ const routes: Routes = [
resolve: {
offices: OfficesResolver
}
},
{
path: 'shares',
loadChildren: () => import('../shares/shares.module').then((m) => m.SharesModule)
}
])
];
@@ -0,0 +1,87 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container aml-rules-container">
<mat-card class="aml-card">
<mat-card-header class="aml-card-header">
<div class="aml-header-content">
<mat-icon class="header-icon">shield</mat-icon>
<div>
<mat-card-title class="header-title">AML Rules Master</mat-card-title>
<mat-card-subtitle class="header-subtitle">
Database-driven rules for client risk categorization and AML compliance checks
</mat-card-subtitle>
</div>
</div>
</mat-card-header>
<mat-card-content class="aml-card-content">
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column -->
<ng-container matColumnDef="orderPosition">
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
</ng-container>
<!-- Rule Name Column -->
<ng-container matColumnDef="ruleName">
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
</ng-container>
<!-- Risk Category Column -->
<ng-container matColumnDef="riskCategory">
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
<span class="dot"></span>
{{ getRiskCategoryLabel(element.riskCategory) }}
</span>
</td>
</ng-container>
<!-- Reason Column -->
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
</ng-container>
<!-- AML Check Status Column -->
<ng-container matColumnDef="amlCheckStatus">
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
{{ element.amlCheckStatus || 'CLEAR' }}
</span>
</td>
</ng-container>
<!-- Banned Entity Match Column -->
<ng-container matColumnDef="bannedEntityMatch">
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
<td mat-cell *matCellDef="let element" class="col-center">
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
{{ element.bannedEntityMatch || 'NO' }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
</table>
<!-- Mat Paginator for standard pagination -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
<div *ngIf="rules.data.length === 0" class="no-rules-message">
<mat-icon>info_outline</mat-icon>
<span>No AML rules found in the database.</span>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.aml-rules-container {
padding: 24px;
background-color: var(--bg-neutral-light, #f8fafc);
min-height: calc(100vh - 64px);
}
.aml-card {
border-radius: 12px;
box-shadow:
0 4px 20px -2px rgb(0 0 0 / 5%),
0 2px 8px -1px rgb(0 0 0 / 3%);
border: 1px solid rgb(226 232 240 / 80%);
background: #fff;
overflow: hidden;
}
.aml-card-header {
background: linear-gradient(135deg, #1e293b, #0f172a);
color: #fff;
padding: 24px;
}
.aml-header-content {
display: flex;
align-items: center;
gap: 16px;
.header-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #38bdf8;
}
.header-title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #fff;
margin-bottom: 4px;
}
.header-subtitle {
font-size: 13px;
color: #94a3b8;
}
}
.aml-card-content {
padding: 24px;
}
.aml-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
th {
background-color: #f1f5f9;
color: #475569;
font-weight: 600;
font-size: 13px;
padding: 16px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 16px;
font-size: 14px;
color: #334155;
border-bottom: 1px solid #e2e8f0;
}
.rule-row {
transition: background-color 0.2s ease;
&:hover {
background-color: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
}
.col-center {
text-align: center;
justify-content: center;
}
.rule-name-cell {
font-weight: 500;
color: #1e293b;
max-width: 300px;
}
.reason-cell {
color: #64748b;
font-size: 13px;
max-width: 400px;
}
/* Badges for Risk Category */
.risk-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
&.risk-high {
background-color: #fef2f2;
color: #ef4444;
border: 1px solid #fee2e2;
.dot {
background-color: #ef4444;
}
}
&.risk-medium {
background-color: #fffbeb;
color: #d97706;
border: 1px solid #fef3c7;
.dot {
background-color: #d97706;
}
}
&.risk-low {
background-color: #f0fdf4;
color: #16a34a;
border: 1px solid #dcfce7;
.dot {
background-color: #16a34a;
}
}
}
/* Badges for Compliance status */
.compliance-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
/* AML Statuses */
&.status-clear {
background-color: #dcfce7;
color: #15803d;
}
&.status-flagged {
background-color: #fef3c7;
color: #b45309;
}
&.status-blocked {
background-color: #fee2e2;
color: #b91c1c;
}
/* Banned Matches */
&.match-no {
background-color: #f1f5f9;
color: #475569;
}
&.match-yes {
background-color: #fee2e2;
color: #b91c1c;
}
&.match-partial_match {
background-color: #ffedd5;
color: #c2410c;
}
}
.no-rules-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #64748b;
gap: 12px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
.aml-paginator {
background: #fff;
border-top: 1px solid #e2e8f0;
padding: 8px 0;
font-size: 13px;
color: #475569;
}
@@ -0,0 +1,87 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { CommonModule } from '@angular/common';
/** Custom Services */
import { ClientsService } from '../../clients/clients.service';
@Component({
selector: 'mifosx-aml-rules',
templateUrl: './aml-rules.component.html',
styleUrls: ['./aml-rules.component.scss'],
standalone: true,
imports: [
CommonModule,
MatTableModule,
MatCardModule,
MatIconModule,
MatButtonModule,
MatCheckboxModule,
MatPaginatorModule
]
})
export class AmlRulesComponent implements OnInit {
private clientsService = inject(ClientsService);
rules = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'orderPosition',
'ruleName',
'riskCategory',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
];
@ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.rules.data = data || [];
this.rules.paginator = this.paginator;
},
error: () => {}
});
}
getRiskCategoryLabel(category: number): string {
switch (category) {
case 1:
return 'Low Risk';
case 2:
return 'Medium Risk';
case 3:
return 'High Risk';
default:
return 'Unknown';
}
}
getRiskColorClass(category: number): string {
switch (category) {
case 1:
return 'risk-low';
case 2:
return 'risk-medium';
case 3:
return 'risk-high';
default:
return '';
}
}
}
@@ -11,6 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RbiCircularsComponent } from './rbi-circulars.component';
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
const routes: Routes = [
Route.withShell([
@@ -18,6 +19,11 @@ const routes: Routes = [
path: 'rbi-circulars',
component: RbiCircularsComponent,
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
},
{
path: 'rbi/aml',
component: AmlRulesComponent,
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
}
])
];
@@ -0,0 +1,130 @@
<!--
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/.
-->
<mat-card class="container">
<div class="table-header">
<div class="table-header-row">
<div class="table-title-group">
<h2 class="table-title">
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
Share Holders
</h2>
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
</div>
<div class="table-buttons"></div>
</div>
<div class="table-filters">
<mat-form-field class="table-search" appearance="outline">
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
<mat-option value="">All Branches</mat-option>
<mat-option *ngFor="let office of offices" [value]="office.id">
{{ office.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
<mat-option value="">All Products</mat-option>
<mat-option *ngFor="let product of products" [value]="product.id">
{{ product.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option>
<mat-option value="approved">Approved</mat-option>
<mat-option value="submitted and pending approval">Pending</mat-option>
<mat-option value="closed">Closed</mat-option>
<mat-option value="rejected">Rejected</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-date-range-picker #picker></mat-date-range-picker>
</mat-form-field>
<button mat-icon-button matTooltip="Clear Filters" (click)="clearFilters()">
<mat-icon>clear</mat-icon>
</button>
</div>
</div>
<div class="table-container">
<mat-progress-bar *ngIf="isLoading" mode="indeterminate" class="table-loader"></mat-progress-bar>
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<!-- Member Name Column (clientName) -->
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client Name</th>
<td mat-cell *matCellDef="let element">{{ element.clientName || 'N/A' }}</td>
</ng-container>
<!-- Folio Number Column (accountNo) -->
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Account No</th>
<td mat-cell *matCellDef="let element">{{ element.accountNo || 'N/A' }}</td>
</ng-container>
<!-- Class Column (productName) -->
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product Name</th>
<td mat-cell *matCellDef="let element">{{ element.productName || 'N/A' }}</td>
</ng-container>
<!-- Approved Shares Column -->
<ng-container matColumnDef="totalApprovedShares">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved Shares</th>
<td mat-cell *matCellDef="let element">{{ element.totalApprovedShares || 0 }}</td>
</ng-container>
<!-- Pending Shares Column -->
<ng-container matColumnDef="totalPendingForApprovalShares">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Pending Shares</th>
<td mat-cell *matCellDef="let element">{{ element.totalPendingForApprovalShares || 0 }}</td>
</ng-container>
<!-- Currency Column -->
<ng-container matColumnDef="currency">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Currency</th>
<td mat-cell *matCellDef="let element">{{ element.currency?.code || 'N/A' }}</td>
</ng-container>
<!-- Activated Date Column -->
<ng-container matColumnDef="activatedDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Activated On</th>
<td mat-cell *matCellDef="let element">
{{ element.activatedDate ? (element.activatedDate | dateFormat) : 'Not Activated' }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<!-- We can route to /shares/{accountId} or /clients/{clientId}/shares-accounts/{accountId}/general depending on preference -->
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="['/clients', row.clientId, 'shares-accounts', row.id, 'general']"
></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</mat-card>
@@ -0,0 +1,127 @@
/**
* 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/.
*/
.container {
margin: 1.5rem;
padding: 0;
border-radius: var(--radius-xl);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-light);
.table-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.table-title-group {
display: flex;
align-items: center;
gap: 1rem;
.table-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
display: flex;
align-items: center;
gap: 0.75rem;
letter-spacing: -0.01em;
.t-logomark {
width: 24px;
height: 24px;
position: relative;
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px;
background: var(--bg-hover);
border-radius: 6px;
span {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--primary-main);
opacity: 0.8;
&:nth-child(2) {
opacity: 0.6;
}
&:nth-child(3) {
opacity: 0.4;
}
&:nth-child(4) {
opacity: 0.2;
}
}
}
}
.table-count {
padding: 0.25rem 0.75rem;
background: var(--bg-hover);
color: var(--text-muted);
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
}
}
.table-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
mat-form-field {
width: 240px;
margin-bottom: -1.25em;
}
}
}
.table-container {
position: relative;
overflow: auto;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
.table-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
}
.modern-table {
width: 100%;
background: transparent;
tr.mat-row {
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--bg-hover);
}
}
}
@@ -0,0 +1,199 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FormsModule, ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, switchMap, tap, startWith } from 'rxjs/operators';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { SharesService } from '../shares.service';
@Component({
selector: 'mifosx-share-holders',
templateUrl: './share-holders.component.html',
styleUrls: ['./share-holders.component.scss'],
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressBarModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
DateFormatPipe
]
})
export class ShareHoldersComponent implements OnInit {
displayedColumns: string[] = [
'clientName',
'accountNo',
'productName',
'totalApprovedShares',
'totalPendingForApprovalShares',
'currency',
'activatedDate'
];
dataSource = new MatTableDataSource<any>();
isLoading = true;
totalRows = 0;
// Filter Data
offices: any[] = [];
products: any[] = [];
// Filter State
searchValue = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
dateRangeForm = new FormGroup({
start: new FormControl<Date | null>(null),
end: new FormControl<Date | null>(null)
});
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort;
searchControl = new FormControl('');
private sharesService = inject(SharesService);
ngOnInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.setupFilterPredicate();
this.searchControl.valueChanges
.pipe(
startWith(''),
debounceTime(400),
distinctUntilChanged(),
filter((term: string | null) => !term || term.trim().length === 0 || term.trim().length >= 2),
tap((term: string | null) => {
this.searchValue = term || '';
this.isLoading = true;
}),
switchMap((term: string | null) => {
const search = term ? term.trim() : '';
return forkJoin({
accounts: this.sharesService.getShareAccountSummaries(search || undefined).pipe(catchError(() => of([]))),
offices: this.sharesService.getOffices().pipe(catchError(() => of([]))),
products: this.sharesService.getShareProducts().pipe(catchError(() => of([])))
});
})
)
.subscribe({
next: (results: any) => {
this.offices = results.offices || [];
const allShareAccounts = results.accounts || [];
// Derive unique products from the actual active share accounts to ensure no garbage/dummy
// products (like NextGen Tech Pvt Ltd) show up in the dropdown.
const uniqueProductsMap = new Map();
allShareAccounts.forEach((acc: any) => {
if (acc.productId && acc.productName) {
uniqueProductsMap.set(acc.productId, { id: acc.productId, name: acc.productName });
}
});
this.products = Array.from(uniqueProductsMap.values());
this.dataSource.data = allShareAccounts;
this.totalRows = allShareAccounts.length;
this.isLoading = false;
},
error: () => {
this.isLoading = false;
}
});
}
setupFilterPredicate() {
this.dataSource.filterPredicate = (data: any, filter: string) => {
let branchMatch = true;
if (this.branchFilter) {
branchMatch = data.officeId?.toString() === this.branchFilter.toString();
}
let productMatch = true;
if (this.productFilter) {
productMatch = data.productId?.toString() === this.productFilter.toString();
}
let statusMatch = true;
if (this.statusFilter) {
statusMatch =
data.status?.value?.toLowerCase() === this.statusFilter.toLowerCase() ||
data.status?.id?.toString() === this.statusFilter.toString();
}
let dateMatch = true;
if (this.dateRangeForm.value.start && data.activatedDate) {
const rowDate = new Date(data.activatedDate[0], data.activatedDate[1] - 1, data.activatedDate[2]);
const start = this.dateRangeForm.value.start;
const end = this.dateRangeForm.value.end || new Date(); // If no end date, use today
// Reset hours for accurate comparison
rowDate.setHours(0, 0, 0, 0);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
dateMatch = rowDate >= start && rowDate <= end;
}
return branchMatch && productMatch && statusMatch && dateMatch;
};
}
applyFilters() {
// We update the filter string to a random/unique value to trigger the filterPredicate
this.dataSource.filter = Math.random().toString();
}
search(filterValue: string) {
this.searchControl.setValue(filterValue);
}
clearFilters() {
this.searchValue = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
this.dateRangeForm.reset();
// Trigger server-side reload without search term
this.searchControl.setValue('', { emitEvent: true });
// Trigger client-side filters update
this.applyFilters();
}
}
+6 -9
View File
@@ -18,7 +18,7 @@ import { DividendsTabComponent } from './shares-account-view/dividends-tab/divid
import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component';
import { EditShareAccountDetailsComponent } from './edit-share-account-details/edit-share-account-details.component';
import { ShareHoldersComponent } from './share-holders/share-holders.component';
/** Custom Resolvers */
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
@@ -33,6 +33,11 @@ const routes: Routes = [
path: '',
data: { title: 'Shares', breadcrumb: 'Shares', routeParamBreadcrumb: false, addBreadcrumbLink: false },
children: [
{
path: 'share-holders',
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
component: ShareHoldersComponent
},
{
path: 'create',
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
@@ -90,14 +95,6 @@ const routes: Routes = [
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'edit-details',
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
component: EditShareAccountDetailsComponent,
resolve: {
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'actions/:name',
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' },
+18
View File
@@ -37,6 +37,15 @@ import { ApproveShareDialogComponent } from './shares-account-actions/approve-sh
import { RejectSharesComponent } from './shares-account-actions/reject-shares/reject-shares.component';
import { RejectShareDialogComponent } from './shares-account-actions/reject-shares/reject-share-dialog/reject-share-dialog.component';
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
import { ShareHoldersComponent } from './share-holders/share-holders.component';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
/**
* Shares Module
@@ -45,6 +54,15 @@ import { GeneralTabComponent } from './shares-account-view/general-tab/general-t
*/
@NgModule({
imports: [
ShareHoldersComponent,
SharedModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatCardModule,
MatProgressBarModule,
MatFormFieldModule,
MatInputModule,
SharedModule,
PipesModule,
DirectivesModule,
+33
View File
@@ -22,6 +22,39 @@ import { Observable } from 'rxjs';
export class SharesService {
private http = inject(HttpClient);
/**
* @param {any} params Optional HTTP params (like offset, limit, search)
* @returns {Observable<any>} List of all share accounts
*/
getSharesAccounts(params?: any): Observable<any> {
return this.http.get('/accounts/share', { params });
}
/**
* @returns {Observable<any>} List of all share accounts with client details summary
*/
getShareAccountSummaries(searchTerm?: string): Observable<any> {
let params = new HttpParams();
if (searchTerm) {
params = params.set('searchTerm', searchTerm);
}
return this.http.get('/shareaccounts/summary', { params });
}
/**
* @returns {Observable<any>} List of all offices
*/
getOffices(): Observable<any> {
return this.http.get('/offices');
}
/**
* @returns {Observable<any>} List of all share products
*/
getShareProducts(): Observable<any> {
return this.http.get('/products/share');
}
/**
* @param {string} accountId Shares Account Id of account to get data for.
* @param {boolean} template Shares account template required?.
@@ -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&hellip;</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 &ldquo;{{ searchTerm }}&rdquo;. 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>
@@ -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;
}
}
@@ -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();
}
}
@@ -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 &mdash; 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>
@@ -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;
}
@@ -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);
}
@@ -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">&mdash;</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&hellip;</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>
@@ -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;
}
}
@@ -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();
}
}
+7 -6
View File
@@ -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,
+20 -3
View File
@@ -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 });
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260728',
'hash': '2e4187c3'
'version': '260731',
'hash': 'ca85bae4'
},
'allow_switching_backend_instance': true
};