Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
a982083b14 |
@@ -0,0 +1,70 @@
|
||||
<!--
|
||||
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 class="mk-title" [class.mk-title--str]="isEscalation">
|
||||
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="mk-entry">
|
||||
<div>
|
||||
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date</label><span>{{ data.transactionDate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Amount</label><span class="mono">₹{{ data.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>GL account</label><span>{{ data.glAccountName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEscalation) {
|
||||
<p class="mk-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
|
||||
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="mk-note">
|
||||
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
|
||||
</p>
|
||||
}
|
||||
|
||||
<label class="mk-field">
|
||||
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
[(ngModel)]="notes"
|
||||
[placeholder]="
|
||||
isEscalation
|
||||
? 'Why is this entry suspicious? What did you check?'
|
||||
: 'What satisfied you that this entry is in order?'
|
||||
"
|
||||
></textarea>
|
||||
</label>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="mk-actions">
|
||||
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
|
||||
<button
|
||||
class="mk-btn"
|
||||
[class.mk-btn--str]="isEscalation"
|
||||
[class.mk-btn--ok]="!isEscalation"
|
||||
[disabled]="!canSubmit"
|
||||
(click)="confirm()"
|
||||
>
|
||||
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--mk-g100: #f1f5f9;
|
||||
--mk-g200: #e2e8f0;
|
||||
--mk-g300: #cbd5e1;
|
||||
--mk-g500: #64748b;
|
||||
--mk-g700: #334155;
|
||||
--mk-g900: #0f172a;
|
||||
--mk-green: #15803d;
|
||||
--mk-red: #c0392b;
|
||||
--mk-amber-l: #fef3c7;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--mk-g900);
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.mk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 18px 22px 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mk-green);
|
||||
|
||||
&--str {
|
||||
color: var(--mk-red);
|
||||
}
|
||||
}
|
||||
|
||||
.mk-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 18px;
|
||||
padding: 11px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--mk-g200);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mk-g500);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mk-warn {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #78350f;
|
||||
background: var(--mk-amber-l);
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.mk-note {
|
||||
font-size: 12px;
|
||||
color: var(--mk-g500);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.mk-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mk-g500);
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--mk-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--mk-g700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mk-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 8px 22px 18px;
|
||||
}
|
||||
|
||||
.mk-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--mk-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--mk-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--mk-g100);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: var(--mk-green);
|
||||
border-color: var(--mk-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
}
|
||||
}
|
||||
|
||||
&--str {
|
||||
background: var(--mk-red);
|
||||
border-color: var(--mk-red);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #a5301f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface MarkEntryDialogData {
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
journalEntryId: number;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
glAccountName: string;
|
||||
transactionDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the reviewer's reason before a ledger entry is marked.
|
||||
*
|
||||
* Notes are not optional on an escalation: the Principal Officer picks the case up
|
||||
* with nothing else to go on, and an STR filed on an unexplained referral is not
|
||||
* defensible. A whitelist may be recorded without them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-mark-entry-dialog',
|
||||
templateUrl: './mark-entry-dialog.component.html',
|
||||
styleUrls: ['./mark-entry-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
]
|
||||
})
|
||||
export class MarkEntryDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
|
||||
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
notes = '';
|
||||
|
||||
get isEscalation(): boolean {
|
||||
return this.data.disposition === 'ESCALATE';
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !this.isEscalation || this.notes.trim().length > 0;
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
if (this.canSubmit) {
|
||||
this.dialogRef.close(this.notes.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (markError) {
|
||||
<div class="gl-mark-error">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>{{ markError }}</span>
|
||||
<button class="gl-mark-error__close" (click)="markError = ''">×</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="gl-table-shell">
|
||||
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
||||
<!-- #ID -->
|
||||
@@ -692,6 +700,50 @@
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- AML review — whitelist or refer the entry to STR -->
|
||||
<ng-container matColumnDef="amlMark">
|
||||
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
|
||||
<mat-cell *matCellDef="let e" class="gl-td">
|
||||
@if (markOf(e); as mark) {
|
||||
<span
|
||||
class="gl-mark"
|
||||
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
|
||||
[matTooltip]="
|
||||
(mark.disposition === 'ESCALATE'
|
||||
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
|
||||
: 'Whitelisted') +
|
||||
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
|
||||
(mark.notes ? ' — ' + mark.notes : '')
|
||||
"
|
||||
>
|
||||
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
|
||||
</span>
|
||||
} @else if (isMarking(e)) {
|
||||
<span class="gl-mark gl-mark--busy">Saving…</span>
|
||||
} @else if (canMark(e)) {
|
||||
<span class="gl-mark-actions">
|
||||
<button
|
||||
class="gl-mark-btn gl-mark-btn--ok"
|
||||
matTooltip="Reviewed and in order — clear this entry"
|
||||
(click)="openMarkDialog(e, 'WHITELIST')"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon> Whitelist
|
||||
</button>
|
||||
<button
|
||||
class="gl-mark-btn gl-mark-btn--str"
|
||||
matTooltip="Refer to the Principal Officer as an STR case"
|
||||
(click)="openMarkDialog(e, 'ESCALATE')"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
|
||||
</button>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
|
||||
}
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
|
||||
|
||||
@@ -1328,6 +1328,17 @@
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
|
||||
// Wide enough to hold both action buttons on one line. Every other column
|
||||
// declares a width; without one this collapsed and clipped its contents.
|
||||
::ng-deep .mat-column-amlMark {
|
||||
width: 184px;
|
||||
min-width: 184px;
|
||||
flex: 0 0 184px;
|
||||
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
::ng-deep .mat-column-actions {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
@@ -1556,6 +1567,112 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── AML review column ─────────────────────────────────────────────────────────
|
||||
// Never wrap — a wrapped second button used to be clipped by the row height.
|
||||
.gl-mark-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gl-mark-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
border-radius: 6px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--c-text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
transition: all var(--transition);
|
||||
|
||||
// Tinted at rest, not only on hover — these have to be findable in a dense
|
||||
// table without the reviewer hunting for them.
|
||||
&--ok {
|
||||
border-color: rgb(16 185 129 / 45%);
|
||||
background: var(--c-cr-bg);
|
||||
color: #047857;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c-cr);
|
||||
background: #d1fae5;
|
||||
}
|
||||
}
|
||||
|
||||
&--str {
|
||||
border-color: rgb(239 68 68 / 45%);
|
||||
background: var(--c-dr-bg);
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c-dr);
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gl-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
background: var(--c-cr-bg);
|
||||
color: #047857;
|
||||
|
||||
&--str {
|
||||
background: var(--c-dr-bg);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--busy {
|
||||
background: #f1f5f9;
|
||||
color: var(--c-text-3);
|
||||
}
|
||||
|
||||
&--na {
|
||||
background: transparent;
|
||||
color: var(--c-text-3);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.gl-mark-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0 0 10px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: var(--c-dr-bg);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #b91c1c;
|
||||
|
||||
&__close {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
.gl-empty {
|
||||
display: flex;
|
||||
|
||||
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
|
||||
|
||||
const STATE_KEY = 'gl_ledger_filter_state';
|
||||
|
||||
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
|
||||
interface EntryMark {
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
strCaseId: number | null;
|
||||
reviewedBy: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-search-journal-entry',
|
||||
standalone: true,
|
||||
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
'manualEntry',
|
||||
'createdByUserName',
|
||||
'submittedOnDate',
|
||||
'amlMark',
|
||||
'actions'
|
||||
];
|
||||
dayWiseColumns = [
|
||||
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
dayWiseData: any[] = [];
|
||||
glAccountInfo: any = null;
|
||||
|
||||
// ── AML marking ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Marks already recorded, by journal entry id. */
|
||||
private marks = new Map<number, EntryMark>();
|
||||
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
|
||||
private marking = new Set<number>();
|
||||
markError = '';
|
||||
|
||||
// ── UI state ──────────────────────────────────────────────────────────────
|
||||
activeQuickFilter = '';
|
||||
activeFiltersCount = 0;
|
||||
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
this.setFilteredOffices();
|
||||
this.getJournalEntries();
|
||||
this.loadLedgerSummary();
|
||||
this.loadMarks();
|
||||
}
|
||||
|
||||
// ── AML marking ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marks are loaded once for the whole ledger rather than per page, because the
|
||||
* user pages and re-filters constantly and the set is small — only entries a
|
||||
* reviewer has actually touched.
|
||||
*/
|
||||
private loadMarks(): void {
|
||||
this.amlService.getManualReferrals().subscribe({
|
||||
next: (rows) => {
|
||||
this.marks = new Map(
|
||||
(rows || []).map((r: any) => [
|
||||
r.journalEntryId,
|
||||
{
|
||||
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
|
||||
strCaseId: r.strCaseId ?? null,
|
||||
reviewedBy: r.reviewedByUsername || '',
|
||||
notes: r.reviewNotes || ''
|
||||
} as EntryMark
|
||||
])
|
||||
);
|
||||
},
|
||||
// A ledger that cannot reach the AML service still has to render.
|
||||
error: () => (this.marks = new Map())
|
||||
});
|
||||
}
|
||||
|
||||
markOf(entry: any): EntryMark | null {
|
||||
return this.marks.get(entry?.id) ?? null;
|
||||
}
|
||||
|
||||
isMarking(entry: any): boolean {
|
||||
return this.marking.has(entry?.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* An alert needs a customer, and a journal entry only reaches one through the
|
||||
* savings, loan or client transaction that produced it. A purely manual GL
|
||||
* posting reaches none, so it cannot be referred — the server enforces this too.
|
||||
*/
|
||||
canMark(entry: any): boolean {
|
||||
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
|
||||
}
|
||||
|
||||
markBlockedReason(entry: any): string {
|
||||
if (this.markOf(entry)) {
|
||||
return 'Already reviewed — a ledger entry is marked once';
|
||||
}
|
||||
return 'No customer behind this entry, so it cannot be referred';
|
||||
}
|
||||
|
||||
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
if (!this.canMark(entry) || this.isMarking(entry)) {
|
||||
return;
|
||||
}
|
||||
const data: MarkEntryDialogData = {
|
||||
disposition,
|
||||
journalEntryId: entry.id,
|
||||
transactionId: entry.transactionId,
|
||||
amount: entry.amount,
|
||||
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
|
||||
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
|
||||
};
|
||||
|
||||
this.dialog
|
||||
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
|
||||
.afterClosed()
|
||||
.subscribe((notes: string | undefined) => {
|
||||
if (notes !== undefined) {
|
||||
this.submitMark(entry, disposition, notes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
|
||||
this.markError = '';
|
||||
this.marking.add(entry.id);
|
||||
|
||||
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
|
||||
next: (res: any) => {
|
||||
this.marking.delete(entry.id);
|
||||
this.marks.set(entry.id, {
|
||||
disposition,
|
||||
strCaseId: res?.strCaseId ?? null,
|
||||
reviewedBy: res?.reviewedByUsername || '',
|
||||
notes
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.marking.delete(entry.id);
|
||||
this.markError =
|
||||
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -34,6 +34,28 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ Manual referrals from the general ledger ═══
|
||||
|
||||
/**
|
||||
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
||||
*
|
||||
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
||||
* journal entry id rather than a savings or loan transaction id, so the ledger
|
||||
* screen can match them to the rows it is showing.
|
||||
*/
|
||||
getManualReferrals(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
||||
* case for the Principal Officer, the same road an escalated engine alert takes.
|
||||
* The entry must have a customer behind it or the server rejects it.
|
||||
*/
|
||||
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ STR cases (Principal Officer) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
|
||||
@@ -69,6 +69,7 @@ import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -180,6 +181,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
@@ -62,6 +62,38 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||
(m) => m.ShareWithdrawalRequestComponent
|
||||
),
|
||||
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||
(m) => m.MyShareWithdrawalRequestsComponent
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
path: 'share-closure-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/share-closure-request/share-closure-request.component').then(
|
||||
(m) => m.ShareClosureRequestComponent
|
||||
),
|
||||
data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'share-closure-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then(
|
||||
(m) => m.MyShareClosureRequestsComponent
|
||||
),
|
||||
data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** HTTP service for the Share Closure maker-checker workflow — mirrors
|
||||
* ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkShareClosureService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/share-closure-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-closure-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-closure-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/share-closure-requests/${id}`);
|
||||
}
|
||||
|
||||
validate(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {});
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkShareWithdrawalService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||
}
|
||||
|
||||
validate(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -231,8 +231,11 @@
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">
|
||||
AMOUNT
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||
>
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -242,20 +245,48 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (row.typ !== 'Withdrawal Slip') {
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
type="date"
|
||||
[(ngModel)]="row.meetingDate"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
@@ -712,6 +743,32 @@
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-field">
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="te-panel__header te-panel__header--mt">
|
||||
@@ -724,7 +781,13 @@
|
||||
<div class="te-panel__body">
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">1.</span>
|
||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
||||
<span class="te-bal-label">{{
|
||||
selectedRow?.acTyp === 'SHR'
|
||||
? 'Share Balance'
|
||||
: accountDetails?.glCode
|
||||
? 'GL Balance'
|
||||
: 'Current Balance'
|
||||
}}</span>
|
||||
<span class="te-bal-value te-bal-value--cr"
|
||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -1281,7 +1344,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -1360,7 +1423,7 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
AMOUNT
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -1370,27 +1433,51 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">MEETING DATE</label>
|
||||
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input
|
||||
|
||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f__hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: $ws-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ws-amt-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
|
||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||
import { CashierDepositService } from '../cashier-deposit.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import {
|
||||
@@ -47,6 +48,10 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
meetingDate?: string;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -753,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||
private cashierDepositService = inject(CashierDepositService);
|
||||
readonly router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -769,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
loadingAccount = false;
|
||||
accountError = '';
|
||||
|
||||
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
* Every other account type/mode keeps the full, unfiltered list. */
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
if (row.acTyp === 'SHR') {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
const isShareSettlementCredit =
|
||||
row.typ === 'Enter Bank Transfer' &&
|
||||
row.dbCr === 'Cr' &&
|
||||
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||
if (isShareSettlementCredit) {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
return this.paymentTypes;
|
||||
}
|
||||
|
||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||
rowAccountIds: Record<string, number> = {};
|
||||
rowAccountNames: Record<string, string> = {};
|
||||
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||
rowSharePrices: Record<string, number> = {};
|
||||
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||
// that isn't necessarily the currently "selected" one.
|
||||
rowClientIds: Record<string, number> = {};
|
||||
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||
|
||||
// Full account + client data
|
||||
accountDetails: any = null;
|
||||
@@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
if (active.acTyp === 'SHR') {
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row: TxnRow = {
|
||||
@@ -1499,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||
* approval both redeems the shares and settles the linked account automatically. */
|
||||
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||
// (Share) row's, which has no real payment-type concept.
|
||||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||
if (!savingsRow.paymentTypeId) {
|
||||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (settlementPaymentType?.isCashPayment) {
|
||||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||||
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||
if (!shareAccountId) {
|
||||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||
const enteredAcNo = savingsRow.acNo?.trim();
|
||||
const matchesLinked =
|
||||
!!linkedAcNo &&
|
||||
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||
if (linkedAcNo && !matchesLinked) {
|
||||
this.showAlert('error', 'Wrong Credit Account', [
|
||||
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||
`You entered: ${enteredAcNo}`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!linkedAcNo) {
|
||||
this.showAlert('error', 'No Linked Savings Account', [
|
||||
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
if (shares <= 0) {
|
||||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.resolutionNumber?.trim()) {
|
||||
this.showAlert('error', 'Board Resolution Number Required', [
|
||||
'A share withdrawal must be authorised by a Board Resolution.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.meetingDate) {
|
||||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||
const settlementRowSnapshot = {
|
||||
branch: savingsRow.br,
|
||||
accountType: savingsRow.acTyp,
|
||||
accountNo: savingsRow.acNo,
|
||||
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||
dbCr: savingsRow.dbCr,
|
||||
amount: savingsRow.amount ?? null,
|
||||
chequeNo: savingsRow.chequeNo || null,
|
||||
chequeDate: savingsRow.cheqDate || null,
|
||||
tokenNo: savingsRow.tokNo || null,
|
||||
description: savingsRow.description || null,
|
||||
bankName: savingsRow.bankName || null,
|
||||
routingCode: savingsRow.routingCode || null,
|
||||
paymentTypeId: savingsRow.paymentTypeId,
|
||||
paymentTypeName: settlementPaymentType?.name ?? null
|
||||
};
|
||||
|
||||
const payload = {
|
||||
shareAccountId,
|
||||
accountNo: shareRow.acNo,
|
||||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||
requestedShares: shares,
|
||||
requestedDate,
|
||||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||
settlementRowSnapshot
|
||||
};
|
||||
|
||||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const confirmLines = [
|
||||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||
`Date of Meeting: ${payload.meetingDate}`,
|
||||
`Settlement Credit To: ${linkedAcNo}`,
|
||||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
|
||||
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||
'',
|
||||
'Validation checks:',
|
||||
` ✓ Share account and linked savings account both loaded`,
|
||||
` ✓ CR account matches this Share account's linked savings account`,
|
||||
` ✓ Settlement Payment Type is non-cash`,
|
||||
` ✓ Board Resolution Number and Date of Meeting provided`,
|
||||
'',
|
||||
'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||
);
|
||||
}
|
||||
|
||||
private doSubmitShareRedemptionTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
shares: number,
|
||||
linkedAcNo: string
|
||||
): void {
|
||||
this.saving = true;
|
||||
this.clerkShareWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Share withdrawal submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||
'Pending Manager approval. Track status in "My Share Requests".'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.rowSharePrices = {};
|
||||
this.rowClientIds = {};
|
||||
this.rowLinkedSavingsAcNo = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||
onTokNoBlur(row: TxnRow): void {
|
||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||
@@ -1535,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||
syncShareLinkedAmounts(): void {
|
||||
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||
if (shareRows.length !== 1) return;
|
||||
const shareRow = shareRows[0];
|
||||
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
const total = Math.round(shares * price * 100) / 100;
|
||||
this.rows.forEach((r) => {
|
||||
if (r !== shareRow) {
|
||||
r.amount = total;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAcNoBlur(row: TxnRow, index: number): void {
|
||||
if (!row.acNo.trim()) return;
|
||||
this.selectedRow = row;
|
||||
@@ -1633,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||
// gated on "exactly one row came back" — it never would be for those types.
|
||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||
const partialMatch =
|
||||
!exactMatch && allItems.length === 1
|
||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
||||
: null;
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
if (match?.id) {
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
@@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||
txnFetchUnsupported?: boolean;
|
||||
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||
siFetchUnsupported?: boolean;
|
||||
maxLimit: number;
|
||||
skipGlobalSearch: boolean;
|
||||
listQueryParam: string;
|
||||
@@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: 4,
|
||||
txnBase: '/accounts/share',
|
||||
txnSearchSupported: false,
|
||||
txnFetchUnsupported: true,
|
||||
siFetchUnsupported: true,
|
||||
maxLimit: 500,
|
||||
skipGlobalSearch: true,
|
||||
listQueryParam: 'accountNo',
|
||||
@@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
txnFetchUnsupported?: boolean;
|
||||
siFetchUnsupported?: boolean;
|
||||
detailParams?: string;
|
||||
},
|
||||
token: number,
|
||||
@@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||
this.rowAccountNames[this.selectedRow.acNo] =
|
||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||
}
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||
// is a different, unrelated account (the linked settlement savings account) and must
|
||||
// never be shown here.
|
||||
this.selectedRow.ecsAcNo =
|
||||
acTyp?.toUpperCase() === 'SHR'
|
||||
? (acc.accountNo ?? '')
|
||||
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||
}
|
||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||
this.computeSameDayNeftRtgs(acc.transactions);
|
||||
this.computeSameDayChequeClearing(acc.transactions);
|
||||
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1913,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||
si: this.http
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
si: cfg.siFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
});
|
||||
|
||||
if (clientIdHint) {
|
||||
@@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// ── Balance getters ────────────────────────────────────────────────────────
|
||||
|
||||
get currentBalance(): number {
|
||||
if (this.selectedRow?.acTyp === 'SHR') {
|
||||
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||
return shares * unitPrice;
|
||||
}
|
||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||
}
|
||||
|
||||
@@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||
if (shareDrRows.length > 0) {
|
||||
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
@@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const accountId = this.accountDetails.id;
|
||||
const cfg = this.accountTypeConfig(row.acTyp);
|
||||
|
||||
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||
// own numeric id, not a real savings account id. Approving that request would call
|
||||
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||
if (row.acTyp === 'SHR') {
|
||||
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||
isDeposit
|
||||
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// GL withdrawal → direct journal entry (no maker-checker)
|
||||
// GL deposit → falls through to maker-checker block below
|
||||
if (row.acTyp === 'GL' && !isDeposit) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||
|
||||
.inv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inv-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.inv-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--matured {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--written_off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CLERK · SHARE CLOSURE</div>
|
||||
<h1 class="pg-title">My Share Closure Requests</h1>
|
||||
<p class="pg-sub">Share account closure requests you have submitted, and their approval status.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Closure Request</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th>Closed Date</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No share closure requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td>{{ formatDateTime(r.closed_date) }}</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||
|
||||
/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this
|
||||
* clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-share-closure-requests',
|
||||
templateUrl: './my-share-closure-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyShareClosureRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkShareClosureService);
|
||||
private router = inject(Router);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
newRequest(): void {
|
||||
this.router.navigate(['/clerk/share-closure-request']);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||
<h1 class="pg-title">My Share Requests</h1>
|
||||
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th class="num">Shares</th>
|
||||
<th>Resolution No.</th>
|
||||
<th>Meeting Date</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td class="num">{{ r.requested_shares }}</td>
|
||||
<td>{{ r.resolution_number }}</td>
|
||||
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
|
||||
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-share-withdrawal-requests',
|
||||
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkShareWithdrawalService);
|
||||
private router = inject(Router);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
newRequest(): void {
|
||||
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE CLOSURE' }}</div>
|
||||
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ isReview ? 'Back to Requests' : 'My Share Closure Requests' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Account</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (!isReview) {
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>* Search Account No. / Client Name (only zero-share accounts are eligible)</span>
|
||||
<mifosx-search-select
|
||||
[options]="accountOptions"
|
||||
[(ngModel)]="selectedAccountId"
|
||||
(ngModelChange)="onAccountSelected($event)"
|
||||
placeholder="Search share account no. or client name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
}
|
||||
@if (account) {
|
||||
<label class="inv-form-field">
|
||||
<span>Account No</span>
|
||||
<input type="text" [value]="account.accountNo" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Client</span>
|
||||
<input type="text" [value]="account.clientName" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Shares Held</span>
|
||||
<input type="text" [value]="heldShares" readonly />
|
||||
</label>
|
||||
}
|
||||
@if (loadingAccount) {
|
||||
<div class="pg-empty">Loading account…</div>
|
||||
}
|
||||
@if (account && heldShares !== 0 && !isReview) {
|
||||
<div class="pg-empty" style="grid-column: span 2; color: #b42318">
|
||||
This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share
|
||||
Withdrawal workflow before it becomes eligible for closure.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isReview && reviewRequest) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Client Compliance</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<div class="inv-form-field">
|
||||
<span>KYC Status</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||
>
|
||||
{{ reviewRequest.client_kyc_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Risk Category</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||
>
|
||||
{{ reviewRequest.client_risk_category_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Open AML Alerts</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||
>
|
||||
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (account) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Closure Details</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>* Closed Date</span>
|
||||
<input type="date" [(ngModel)]="closedDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>Note (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="note"
|
||||
[disabled]="isReview"
|
||||
placeholder="e.g. Member exit — all shares redeemed"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (!isReview) {
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!isFormValid || submitting"
|
||||
(click)="openConfirmDialog()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Verification</div>
|
||||
<div class="swrv-trigger">
|
||||
<div class="swrv-trigger__icon">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</div>
|
||||
<div class="swrv-trigger__text">
|
||||
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||
<div class="swrv-trigger__sub">
|
||||
Re-checks shares held, account status, active loan/guarantee and client compliance before you approve.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
Validate Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Close Account
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showValidateModal) {
|
||||
<div class="swrv-modal">
|
||||
<div class="swrv-modal__card">
|
||||
<div class="swrv-modal__header">
|
||||
<div class="swrv-modal__title">
|
||||
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||
Validate Share Closure Request
|
||||
</div>
|
||||
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__body">
|
||||
<p class="swrv-modal__intro">
|
||||
Re-checking closure of <strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the
|
||||
live account, loan/guarantee and compliance state — not just what was captured at submission time.
|
||||
</p>
|
||||
|
||||
@if (backendChecks && !validating && !validateAllPassed) {
|
||||
<div class="swrv-modal__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>Some checks need review before approving.</strong>
|
||||
Scroll down to see which ones failed and why.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ol class="swrv-steps">
|
||||
@for (step of validateSteps; track step.label; let i = $index) {
|
||||
<li
|
||||
class="swrv-step"
|
||||
[class.swrv-step--active]="step.status === 'active'"
|
||||
[class.swrv-step--done]="step.status === 'done'"
|
||||
[class.swrv-step--failed]="step.status === 'failed'"
|
||||
>
|
||||
<div class="swrv-step__indicator">
|
||||
@if (step.status === 'done') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (step.status === 'active') {
|
||||
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||
} @else if (step.status === 'failed') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="swrv-step__content">
|
||||
<div class="swrv-step__label">{{ step.label }}</div>
|
||||
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (validating && validateSteps.length === 0) {
|
||||
<div class="pg-empty">Connecting to server…</div>
|
||||
}
|
||||
|
||||
@if (backendChecks && !validating && validateAllPassed) {
|
||||
<div class="swrv-modal__result">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>All checks passed — safe to approve.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__footer">
|
||||
@if (!validating && backendChecks) {
|
||||
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Re-run
|
||||
</button>
|
||||
}
|
||||
@if (!validating && backendChecks && validateAllPassed) {
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Close Account
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||
{{ validating ? 'Validating…' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && activityLog.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Activity Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of activityLog; track entry.timestamp) {
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||
<td>{{ entry.action }}</td>
|
||||
<td>{{ entry.user }}</td>
|
||||
<td>{{ entry.note || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConfirmDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Confirm Share Closure Request</h3>
|
||||
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account No / Client</td>
|
||||
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares Held</td>
|
||||
<td>{{ heldShares }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Closed Date</td>
|
||||
<td>{{ closedDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Note</td>
|
||||
<td>{{ note || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
@for (check of verificationChecks; track check.label) {
|
||||
<tr>
|
||||
<td>{{ check.label }}</td>
|
||||
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showRejectDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Reject Share Closure Request</h3>
|
||||
<label class="inv-form-field">
|
||||
<span>* Rejection Reason</span>
|
||||
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||
</label>
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
[disabled]="!rejectionReason.trim()"
|
||||
(click)="confirmRejectReview()"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||
|
||||
export type ShareClosureMode = 'create' | 'review';
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
user: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's
|
||||
* Share Account that already holds zero shares (all shares already withdrawn), enters the closed
|
||||
* date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point
|
||||
* a share account closure should go through — approval server-side executes the real Fineract
|
||||
* "close" command (see ClerkShareClosureRequestApiResource on the backend).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-closure-request',
|
||||
templateUrl: './share-closure-request.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'../share-withdrawal-request/share-withdrawal-request.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class ShareClosureRequestComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private sharesService = inject(SharesService);
|
||||
private closureService = inject(ClerkShareClosureService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||
* events instead of navigating away. */
|
||||
@Input() embedded = false;
|
||||
@Input() overrideMode: ShareClosureMode | null = null;
|
||||
@Input() overrideRequestId: number | null = null;
|
||||
@Output() closeRequested = new EventEmitter<void>();
|
||||
@Output() actionCompleted = new EventEmitter<void>();
|
||||
|
||||
mode: ShareClosureMode = 'create';
|
||||
requestId: number | null = null;
|
||||
|
||||
submitting = false;
|
||||
loadingAccount = false;
|
||||
|
||||
selectedAccountId = '';
|
||||
accountOptions: SearchSelectOption[] = [];
|
||||
|
||||
account: any = null;
|
||||
|
||||
closedDate = '';
|
||||
note = '';
|
||||
|
||||
activityLog: ActivityEntry[] = [];
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
showConfirmDialog = false;
|
||||
|
||||
showValidateModal = false;
|
||||
validating = false;
|
||||
validateAllPassed = false;
|
||||
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||
[];
|
||||
|
||||
get heldShares(): number {
|
||||
return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.account) return false;
|
||||
if (this.heldShares !== 0) return false;
|
||||
if (!this.closedDate) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||
return [
|
||||
{ label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` },
|
||||
{ label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' }
|
||||
];
|
||||
}
|
||||
|
||||
get isReview(): boolean {
|
||||
return this.mode === 'review';
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request';
|
||||
}
|
||||
|
||||
get pageSubtitle(): string {
|
||||
return this.isReview
|
||||
? "Approve or reject the clerk's share account closure request."
|
||||
: 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create';
|
||||
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||
this.requestId = idParam ? Number(idParam) : null;
|
||||
|
||||
const today = new Date();
|
||||
this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
this.sharesService
|
||||
.getShareAccountSummaries()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any[]) => {
|
||||
this.accountOptions = (rows || [])
|
||||
.filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0)
|
||||
.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: `${r.accountNo} — ${r.clientName} (0 shares held)`,
|
||||
searchText: `${r.accountNo} ${r.clientName}`
|
||||
}));
|
||||
});
|
||||
|
||||
if (this.requestId && this.mode === 'review') {
|
||||
this.loadReviewRequest(this.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountSelected(accountId: string): void {
|
||||
this.selectedAccountId = accountId;
|
||||
if (!accountId) {
|
||||
this.account = null;
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = true;
|
||||
this.sharesService
|
||||
.getSharesAccountData(accountId, false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
this.account = acc;
|
||||
this.loadingAccount = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadReviewRequest(id: number): void {
|
||||
this.closureService.getById(id).subscribe({
|
||||
next: (req: any) => {
|
||||
this.reviewRequest = req;
|
||||
this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate;
|
||||
this.note = req.note ?? '';
|
||||
|
||||
if (req.activity_log) {
|
||||
try {
|
||||
this.activityLog = JSON.parse(req.activity_log);
|
||||
} catch {
|
||||
this.activityLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService
|
||||
.getSharesAccountData(String(req.share_account_id), false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => (this.account = acc));
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to load share closure request.', 'Close', { duration: 5000 })
|
||||
});
|
||||
}
|
||||
|
||||
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||
openConfirmDialog(): void {
|
||||
if (!this.isFormValid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
|
||||
cancelConfirmDialog(): void {
|
||||
this.showConfirmDialog = false;
|
||||
}
|
||||
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
this.submitting = true;
|
||||
const payload = {
|
||||
shareAccountId: Number(this.selectedAccountId),
|
||||
accountNo: this.account?.accountNo,
|
||||
clientId: this.account?.clientId,
|
||||
clientName: this.account?.clientName,
|
||||
closedDate: this.closedDate,
|
||||
note: this.note
|
||||
};
|
||||
this.closureService.submit(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share closure request submitted for approval.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openValidateModal(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.showValidateModal = true;
|
||||
this.runBackendValidation();
|
||||
}
|
||||
|
||||
closeValidateModal(): void {
|
||||
if (this.validating) return;
|
||||
this.showValidateModal = false;
|
||||
}
|
||||
|
||||
/** Re-checks live backend state (shares still zero, account still active, no active loan/guarantee,
|
||||
* compliance standing) rather than relying on data already loaded in the browser — catches drift
|
||||
* since submission. Results are revealed one step at a time for the pipeline-style animation. */
|
||||
runBackendValidation(): void {
|
||||
if (!this.reviewRequest || this.validating) return;
|
||||
this.validating = true;
|
||||
this.backendChecks = null;
|
||||
this.validateSteps = [];
|
||||
this.closureService.validate(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||
this.backendChecks = checks;
|
||||
this.validateAllPassed = !!res?.allPassed;
|
||||
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||
this.revealValidateSteps(0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.validating = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revealValidateSteps(index: number): void {
|
||||
if (index >= this.validateSteps.length) {
|
||||
this.validating = false;
|
||||
return;
|
||||
}
|
||||
this.validateSteps[index].status = 'active';
|
||||
setTimeout(() => {
|
||||
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||
this.revealValidateSteps(index + 1);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.closureService.approve(this.reviewRequest.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(): void {
|
||||
this.showRejectDialog = true;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||
this.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.showRejectDialog = false;
|
||||
this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (this.embedded) {
|
||||
this.closeRequested.emit();
|
||||
return;
|
||||
}
|
||||
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-closure-requests'] : ['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Account</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (!isReview) {
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>* Search Account No. / Client Name</span>
|
||||
<mifosx-search-select
|
||||
[options]="accountOptions"
|
||||
[(ngModel)]="selectedAccountId"
|
||||
(ngModelChange)="onAccountSelected($event)"
|
||||
placeholder="Search share account no. or client name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
}
|
||||
@if (account) {
|
||||
<label class="inv-form-field">
|
||||
<span>Account No</span>
|
||||
<input type="text" [value]="account.accountNo" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Client</span>
|
||||
<input type="text" [value]="account.clientName" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Shares Held</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Unit Price</span>
|
||||
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
}
|
||||
@if (loadingAccount) {
|
||||
<div class="pg-empty">Loading account…</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isReview && reviewRequest) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Client Compliance</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<div class="inv-form-field">
|
||||
<span>KYC Status</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||
>
|
||||
{{ reviewRequest.client_kyc_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Risk Category</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||
>
|
||||
{{ reviewRequest.client_risk_category_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Open AML Alerts</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||
>
|
||||
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (account) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Withdrawal Details</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>* Shares to Withdraw</span>
|
||||
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Withdrawal Value</span>
|
||||
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Remaining Shares After Withdrawal</span>
|
||||
<input type="text" [value]="remainingShares" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Board Resolution Number</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Date of Meeting</span>
|
||||
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Settlement Payment Type</span>
|
||||
@if (!isReview) {
|
||||
<select [(ngModel)]="settlementPaymentTypeId">
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (pt of paymentTypeOptions; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||
}
|
||||
</label>
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>Description / Remarks</span>
|
||||
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (!isReview) {
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!isFormValid || submitting"
|
||||
(click)="openConfirmDialog()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Verification</div>
|
||||
<div class="swrv-trigger">
|
||||
<div class="swrv-trigger__icon">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</div>
|
||||
<div class="swrv-trigger__text">
|
||||
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||
<div class="swrv-trigger__sub">
|
||||
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
Validate Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showValidateModal) {
|
||||
<div class="swrv-modal">
|
||||
<div class="swrv-modal__card">
|
||||
<div class="swrv-modal__header">
|
||||
<div class="swrv-modal__title">
|
||||
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||
Validate Share Withdrawal Request
|
||||
</div>
|
||||
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__body">
|
||||
<p class="swrv-modal__intro">
|
||||
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||
and GL configuration — not just what was captured at submission time.
|
||||
</p>
|
||||
|
||||
@if (backendChecks && !validating && !validateAllPassed) {
|
||||
<div class="swrv-modal__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>Some checks need review before approving.</strong>
|
||||
Scroll down to see which ones failed and why.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ol class="swrv-steps">
|
||||
@for (step of validateSteps; track step.label; let i = $index) {
|
||||
<li
|
||||
class="swrv-step"
|
||||
[class.swrv-step--active]="step.status === 'active'"
|
||||
[class.swrv-step--done]="step.status === 'done'"
|
||||
[class.swrv-step--failed]="step.status === 'failed'"
|
||||
>
|
||||
<div class="swrv-step__indicator">
|
||||
@if (step.status === 'done') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (step.status === 'active') {
|
||||
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||
} @else if (step.status === 'failed') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="swrv-step__content">
|
||||
<div class="swrv-step__label">{{ step.label }}</div>
|
||||
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (validating && validateSteps.length === 0) {
|
||||
<div class="pg-empty">Connecting to server…</div>
|
||||
}
|
||||
|
||||
@if (backendChecks && !validating && validateAllPassed) {
|
||||
<div class="swrv-modal__result">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>All checks passed — safe to approve.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__footer">
|
||||
@if (!validating && backendChecks) {
|
||||
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Re-run
|
||||
</button>
|
||||
}
|
||||
@if (!validating && backendChecks && validateAllPassed) {
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||
{{ validating ? 'Validating…' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Settlement</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (reviewRequest?.settlement_amount) {
|
||||
<label class="inv-form-field">
|
||||
<span>Amount Credited</span>
|
||||
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Credited To (Linked Savings A/C)</span>
|
||||
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Transaction Ref.</span>
|
||||
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty" style="grid-column: span 2">
|
||||
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||
already-approved request — check the linked savings account's transaction history to confirm.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && activityLog.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Activity Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of activityLog; track entry.timestamp) {
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||
<td>{{ entry.action }}</td>
|
||||
<td>{{ entry.user }}</td>
|
||||
<td>{{ entry.note || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConfirmDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Confirm Share Withdrawal Request</h3>
|
||||
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account No / Client</td>
|
||||
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares to Withdraw</td>
|
||||
<td>
|
||||
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Remaining Shares After Withdrawal</td>
|
||||
<td>{{ remainingShares }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Requested Date</td>
|
||||
<td>{{ requestedDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Board Resolution Number</td>
|
||||
<td>{{ resolutionNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date of Meeting</td>
|
||||
<td>{{ meetingDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Settlement Payment Type</td>
|
||||
<td>{{ settlementPaymentTypeName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linked Savings Account</td>
|
||||
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
@for (check of verificationChecks; track check.label) {
|
||||
<tr>
|
||||
<td>{{ check.label }}</td>
|
||||
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showRejectDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Reject Share Withdrawal Request</h3>
|
||||
<label class="inv-form-field">
|
||||
<span>* Rejection Reason</span>
|
||||
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||
</label>
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
[disabled]="!rejectionReason.trim()"
|
||||
(click)="confirmRejectReview()"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.swr-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(13 27 42 / 55%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.swr-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&:has(table) {
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-check-pass {
|
||||
color: #1a7f37;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-check-fail {
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--ok {
|
||||
background: #eafaf0;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Verification trigger card ---------- */
|
||||
.swrv-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Validate pipeline modal ---------- */
|
||||
@keyframes swrv-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-zoom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-spin {
|
||||
animation: swrv-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.swrv-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||
// caused. Extra opacity compensates for the loss of the blur look.
|
||||
background: rgb(15 25 45 / 65%);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
animation: swrv-fade 0.2s ease-out;
|
||||
|
||||
&__card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||
animation: swrv-zoom 0.25s ease-out;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__title-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: #b42318;
|
||||
border-color: #b42318;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 16px;
|
||||
|
||||
strong {
|
||||
color: #0d1b2a;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fef3e2;
|
||||
border: 1px solid rgb(217 119 6 / 30%);
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: #633806;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: #4d2a05;
|
||||
}
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
background: #eafaf0;
|
||||
border: 1px solid rgb(26 127 55 / 25%);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a7f37;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid #eef1f5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.swrv-step {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 38px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.25s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__msg {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
.swrv-step__indicator {
|
||||
background: #3457d5;
|
||||
border-color: #3457d5;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #3457d5;
|
||||
}
|
||||
}
|
||||
|
||||
&--done {
|
||||
.swrv-step__indicator {
|
||||
background: #1a7f37;
|
||||
border-color: #1a7f37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #1a7f37;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&--failed {
|
||||
.swrv-step__indicator {
|
||||
background: #b42318;
|
||||
border-color: #b42318;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swrv-spin {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.swrv-modal,
|
||||
.swrv-modal__card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
import { CashierService } from '../../cashier/cashier.service';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||
|
||||
export type ShareWdMode = 'create' | 'review';
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
user: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-withdrawal-request',
|
||||
templateUrl: './share-withdrawal-request.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./share-withdrawal-request.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private sharesService = inject(SharesService);
|
||||
private wdService = inject(ClerkShareWithdrawalService);
|
||||
private cashierService = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||
* events instead of navigating away. */
|
||||
@Input() embedded = false;
|
||||
@Input() overrideMode: ShareWdMode | null = null;
|
||||
@Input() overrideRequestId: number | null = null;
|
||||
@Output() closeRequested = new EventEmitter<void>();
|
||||
@Output() actionCompleted = new EventEmitter<void>();
|
||||
|
||||
mode: ShareWdMode = 'create';
|
||||
requestId: number | null = null;
|
||||
|
||||
submitting = false;
|
||||
loadingAccount = false;
|
||||
|
||||
selectedAccountId = '';
|
||||
accountOptions: SearchSelectOption[] = [];
|
||||
|
||||
account: any = null;
|
||||
|
||||
requestedShares: number | null = null;
|
||||
requestedDate = '';
|
||||
resolutionNumber = '';
|
||||
meetingDate = '';
|
||||
description = '';
|
||||
|
||||
paymentTypes: any[] = [];
|
||||
settlementPaymentTypeId: number | null = null;
|
||||
|
||||
activityLog: ActivityEntry[] = [];
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
showConfirmDialog = false;
|
||||
|
||||
showValidateModal = false;
|
||||
validating = false;
|
||||
validateAllPassed = false;
|
||||
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||
[];
|
||||
|
||||
get remainingShares(): number {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
return held - (this.requestedShares || 0);
|
||||
}
|
||||
|
||||
get unitPrice(): number {
|
||||
return Number(this.account?.currentMarketPrice ?? 0);
|
||||
}
|
||||
|
||||
get withdrawalValue(): number {
|
||||
return (this.requestedShares || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
get paymentTypeOptions(): any[] {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
|
||||
get selectedSettlementPaymentType(): any {
|
||||
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||
}
|
||||
|
||||
get settlementPaymentTypeName(): string {
|
||||
if (this.isReview) {
|
||||
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||
}
|
||||
return this.selectedSettlementPaymentType?.name || '—';
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.account) return false;
|
||||
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
if (this.requestedShares > held) return false;
|
||||
if (!this.requestedDate) return false;
|
||||
if (!this.resolutionNumber.trim()) return false;
|
||||
if (!this.meetingDate) return false;
|
||||
if (!this.settlementPaymentTypeId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
const shares = this.requestedShares || 0;
|
||||
return [
|
||||
{
|
||||
label: 'Board Resolution Number provided',
|
||||
pass: !!this.resolutionNumber?.trim(),
|
||||
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||
},
|
||||
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||
{
|
||||
label: 'Shares requested within shares held',
|
||||
pass: shares > 0 && shares <= held,
|
||||
detail: `${shares} of ${held} shares held`
|
||||
},
|
||||
{
|
||||
label: 'Linked savings account confirmed',
|
||||
pass: !!this.account?.savingsAccountNumber,
|
||||
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||
},
|
||||
{
|
||||
label: 'Settlement Payment Type selected (non-cash)',
|
||||
pass: this.settlementPaymentTypeName !== '—',
|
||||
detail: this.settlementPaymentTypeName
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get isReview(): boolean {
|
||||
return this.mode === 'review';
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||
}
|
||||
|
||||
get pageSubtitle(): string {
|
||||
return this.isReview
|
||||
? "Approve or reject the clerk's share withdrawal request."
|
||||
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||
this.requestId = idParam ? Number(idParam) : null;
|
||||
|
||||
const today = new Date();
|
||||
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
this.meetingDate = this.requestedDate;
|
||||
|
||||
this.sharesService
|
||||
.getShareAccountSummaries()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any[]) => {
|
||||
this.accountOptions = (rows || [])
|
||||
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||
.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||
searchText: `${r.accountNo} ${r.clientName}`
|
||||
}));
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||
|
||||
if (this.requestId && this.mode === 'review') {
|
||||
this.loadReviewRequest(this.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountSelected(accountId: string): void {
|
||||
this.selectedAccountId = accountId;
|
||||
if (!accountId) {
|
||||
this.account = null;
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = true;
|
||||
this.sharesService
|
||||
.getSharesAccountData(accountId, false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
this.account = acc;
|
||||
this.loadingAccount = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadReviewRequest(id: number): void {
|
||||
this.wdService.getById(id).subscribe({
|
||||
next: (req: any) => {
|
||||
this.reviewRequest = req;
|
||||
this.requestedShares = Number(req.requested_shares);
|
||||
this.requestedDate = req.requested_date
|
||||
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||
: this.requestedDate;
|
||||
this.resolutionNumber = req.resolution_number ?? '';
|
||||
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||
this.description = req.description ?? '';
|
||||
|
||||
if (req.activity_log) {
|
||||
try {
|
||||
this.activityLog = JSON.parse(req.activity_log);
|
||||
} catch {
|
||||
this.activityLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService
|
||||
.getSharesAccountData(String(req.share_account_id), false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => (this.account = acc));
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||
});
|
||||
}
|
||||
|
||||
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||
openConfirmDialog(): void {
|
||||
if (!this.isFormValid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
|
||||
cancelConfirmDialog(): void {
|
||||
this.showConfirmDialog = false;
|
||||
}
|
||||
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
this.submitting = true;
|
||||
const payload = {
|
||||
shareAccountId: Number(this.selectedAccountId),
|
||||
accountNo: this.account?.accountNo,
|
||||
clientId: this.account?.clientId,
|
||||
clientName: this.account?.clientName,
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.requestedDate,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.meetingDate,
|
||||
description: this.description,
|
||||
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||
};
|
||||
this.wdService.submit(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openValidateModal(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.showValidateModal = true;
|
||||
this.runBackendValidation();
|
||||
}
|
||||
|
||||
closeValidateModal(): void {
|
||||
if (this.validating) return;
|
||||
this.showValidateModal = false;
|
||||
}
|
||||
|
||||
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||
runBackendValidation(): void {
|
||||
if (!this.reviewRequest || this.validating) return;
|
||||
this.validating = true;
|
||||
this.backendChecks = null;
|
||||
this.validateSteps = [];
|
||||
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||
this.backendChecks = checks;
|
||||
this.validateAllPassed = !!res?.allPassed;
|
||||
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||
this.revealValidateSteps(0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.validating = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revealValidateSteps(index: number): void {
|
||||
if (index >= this.validateSteps.length) {
|
||||
this.validating = false;
|
||||
return;
|
||||
}
|
||||
this.validateSteps[index].status = 'active';
|
||||
setTimeout(() => {
|
||||
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||
this.revealValidateSteps(index + 1);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
let message =
|
||||
res?.settlementAmount != null
|
||||
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||
if (res?.autoClosureRequestId) {
|
||||
message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.';
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(): void {
|
||||
this.showRejectDialog = true;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.showRejectDialog = false;
|
||||
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (this.embedded) {
|
||||
this.closeRequested.emit();
|
||||
return;
|
||||
}
|
||||
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
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="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div
|
||||
[formGroupName]="$index"
|
||||
style="
|
||||
position: relative;
|
||||
padding: 24px 16px 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
"
|
||||
>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index))
|
||||
.get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||
$any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) {
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor() {
|
||||
this.genderIdOptions = this.data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
}
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.childrenForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.childrenForm.valid) {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -139,6 +139,21 @@
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@@ -447,6 +462,25 @@
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (m.children && m.children.length > 0) {
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||
m.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
linkedClientId: [''],
|
||||
hasChildren: [false],
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.children.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
genderIdOptions: this.data.options.genderIdOptions
|
||||
},
|
||||
width: '500px'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
if (this.children.length === 0) {
|
||||
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle marriage certificate file selection */
|
||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Format children dates
|
||||
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||
member.children = member.children.map((child: any) => {
|
||||
const c = { ...child };
|
||||
if (c.dateOfBirth instanceof Date) {
|
||||
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||
} else if (!c.dateOfBirth) {
|
||||
delete c.dateOfBirth;
|
||||
}
|
||||
if (c.panNumber) {
|
||||
c.panNumber = c.panNumber.toUpperCase();
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
delete member.children;
|
||||
}
|
||||
delete member.hasChildren;
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
|
||||
@@ -966,6 +966,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||
this.linkIncomeToTurnover();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||
* and annual income for annual/yearly turnover).
|
||||
*/
|
||||
private linkIncomeToTurnover(): void {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
if (monthlyIncome && monthlyTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (annualIncome && annualTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -723,6 +723,53 @@
|
||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="exception-flag-box"
|
||||
style="
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<h5
|
||||
style="
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exception Flag
|
||||
</h5>
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
|
||||
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
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';
|
||||
@@ -152,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocumentName: 'KYC Document Type',
|
||||
kycDocumentNumber: 'KYC Document Number',
|
||||
amlCheckStatus: 'AML Check Status',
|
||||
bannedEntityMatch: 'Banned Entity List Match'
|
||||
bannedEntityMatch: 'Banned Entity List Match',
|
||||
remarks: 'Reason for Re-edit'
|
||||
};
|
||||
|
||||
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||
@@ -274,6 +276,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
riskCalculationReason = '';
|
||||
showRiskRulesTable = false;
|
||||
|
||||
/**
|
||||
* Exception state as last read back from the server. Needed to tell "this client never had an
|
||||
* exception" from "this client had one and has now cleared it" — only the latter should be
|
||||
* stamped resolved and surface in the Resolved tab.
|
||||
*/
|
||||
persistedIsException = false;
|
||||
persistedExceptionReason: string | null = null;
|
||||
|
||||
/** PAN verification */
|
||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
panVerifying = false;
|
||||
@@ -423,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
aadhaarNumber: [
|
||||
'',
|
||||
[
|
||||
Validators.pattern(/^\d{12}$/),
|
||||
Validators.maxLength(12)
|
||||
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
|
||||
Validators.maxLength(14)
|
||||
]
|
||||
],
|
||||
panAadhaarSeedingStatus: [''],
|
||||
@@ -468,6 +478,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
allowRiskOverride: [false],
|
||||
allowAmlOverride: [false],
|
||||
remarks: [''],
|
||||
isException: [false],
|
||||
exceptionReason: [[]],
|
||||
amlCheckStatus: [
|
||||
'CLEAR',
|
||||
Validators.required
|
||||
@@ -610,8 +622,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
dormantFlag: 'ACTIVE',
|
||||
accountStatus: 'ACTIVE',
|
||||
form60Type: 'FORM_60',
|
||||
form15Type: '15G'
|
||||
form15Type: '15G',
|
||||
isException: false,
|
||||
exceptionReason: []
|
||||
});
|
||||
this.persistedIsException = false;
|
||||
this.persistedExceptionReason = null;
|
||||
this.kycId = '';
|
||||
this.kycLoaded = false;
|
||||
this.kycStatus = 0;
|
||||
@@ -942,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
form60Date: parseDate(data.form60Date),
|
||||
form15Applicable: data.form15Applicable || false,
|
||||
form15Type: data.form15Type || '15G',
|
||||
form15FY: data.form15FY || ''
|
||||
form15FY: data.form15FY || '',
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
// Set remarks as required if KYC is verified
|
||||
if (this.isKycVerified) {
|
||||
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
||||
}
|
||||
this.persistedIsException = data.isException === true;
|
||||
this.persistedExceptionReason = data.exceptionReason || null;
|
||||
// Sync chip-picker selection with the prefilled document name
|
||||
if (data.kycDocumentName) {
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
||||
@@ -1843,7 +1869,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
form15Applicable: v.form15Applicable || false,
|
||||
form15Type: v.form15Type || null,
|
||||
form15FY: v.form15FY || null,
|
||||
remarks: v.remarks || null
|
||||
remarks: v.remarks || null,
|
||||
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
|
||||
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
|
||||
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
|
||||
...deriveKycExceptionFields({
|
||||
panVerified: this.isNonIndividual
|
||||
? this.entPanVerifyStatus === 'VERIFIED'
|
||||
: this.panVerifyStatus === 'VERIFIED',
|
||||
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
|
||||
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
|
||||
// company with a permanent exception.
|
||||
aadhaarApplicable: !this.isNonIndividual,
|
||||
hadException: this.persistedIsException,
|
||||
previousReason: this.persistedExceptionReason,
|
||||
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
|
||||
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1856,7 +1898,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
v.enterprisePan ||
|
||||
v.cinNumber ||
|
||||
v.kycDocumentName ||
|
||||
v.form15Applicable
|
||||
v.form15Applicable ||
|
||||
v.isException
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||
|
||||
@@ -193,6 +194,11 @@ const routes: Routes = [
|
||||
component: Form60TabComponent,
|
||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'form121',
|
||||
component: Form121TabComponent,
|
||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'documents',
|
||||
component: DocumentsTabComponent,
|
||||
|
||||
@@ -545,6 +545,15 @@
|
||||
>
|
||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./form121']"
|
||||
routerLinkActive
|
||||
#form121="routerLinkActive"
|
||||
[active]="form121.isActive"
|
||||
>
|
||||
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./documents']"
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<!--
|
||||
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="f121">
|
||||
@if (loading) {
|
||||
<div class="f121__loading">
|
||||
<mat-spinner [diameter]="36"></mat-spinner>
|
||||
<span>Reading the customer's deposit ledger…</span>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── Header ── -->
|
||||
<div class="f121__head">
|
||||
<div>
|
||||
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||
<div class="f121__sub">
|
||||
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f121__controls">
|
||||
@if (availableFys.length) {
|
||||
<label class="f121__field">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||
@for (year of availableFys; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<div class="f121__field">
|
||||
<span>Settlement</span>
|
||||
<div
|
||||
class="f121__mode"
|
||||
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||
>
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!schedule) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
@if (!dateOfBirth) {
|
||||
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||
Data tab.
|
||||
} @else {
|
||||
No interest has been credited to this customer's deposits yet.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── KPI strip ── -->
|
||||
<div class="f121__kpis">
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||
</div>
|
||||
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">TDS deducted</div>
|
||||
</div>
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||
</div>
|
||||
@if (thresholdCrossed) {
|
||||
<div class="f121__kpi f121__kpi--warn">
|
||||
<div class="f121__kpi-n">Crossed</div>
|
||||
<div class="f121__kpi-l">TDS applies this year</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||
</div>
|
||||
}
|
||||
@if (tdsSaved > 0) {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Alerts ── -->
|
||||
@if (variance !== 0) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationNotAppliedToAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationUselessOnJointAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
This customer has filed Form 121, but tax on
|
||||
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||
own.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (shouldSuggestDeclaration) {
|
||||
<div class="f121__note f121__note--tip">
|
||||
<mat-icon>lightbulb_outline</mat-icon>
|
||||
<div>
|
||||
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||
reclaiming it later as a refund.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!panWellFormed) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>
|
||||
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (note of schedule.notes; track note) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Declaration panel ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span>Declaration</span>
|
||||
</div>
|
||||
<div class="f121__kv">
|
||||
<div class="f121__kv-row">
|
||||
<span>Status</span>
|
||||
<span>
|
||||
@if (declarationOnRecord) {
|
||||
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||
} @else {
|
||||
<span class="f121__pill">Not filed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>Form & threshold band</span>
|
||||
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>PAN</span>
|
||||
<span class="f121__mono">
|
||||
{{ pan || '—' }}
|
||||
@if (pan && !panWellFormed) {
|
||||
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (declarationOnRecord && declarationFy) {
|
||||
<div class="f121__kv-row">
|
||||
<span>Declared for</span>
|
||||
<span>FY {{ declarationFy }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Acceptance date</span>
|
||||
<span>
|
||||
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||
the figures below.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (eligibility) {
|
||||
@for (reason of eligibility.reasons; track reason) {
|
||||
<div class="f121__kv-row f121__kv-row--warn">
|
||||
<span>Eligibility</span>
|
||||
<span>{{ reason }}</span>
|
||||
</div>
|
||||
}
|
||||
@for (note of eligibility.unassessed; track note) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Not assessed</span>
|
||||
<span>{{ note }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quarter-wise ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>table_chart</mat-icon>
|
||||
<span>Quarter by quarter</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||
<td>
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="f121__row--total">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (schedule.shieldedInterest > 0) {
|
||||
<div class="f121__foot">
|
||||
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: accounts that count ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<span>Deposits counted towards this customer's TDS</span>
|
||||
</div>
|
||||
@if (liableAccounts.length) {
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th class="num">TDS deducted</th>
|
||||
<th>Withholding</th>
|
||||
<th>Settles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
@if (a.withHoldTax) {
|
||||
<span class="f121__pill f121__pill--warn">On</span>
|
||||
@if (a.taxGroup) {
|
||||
<div class="muted">{{ a.taxGroup }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="f121__pill">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||
whole, not branch by branch.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 2: joint holdings ── -->
|
||||
@if (jointHolderAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>group</mat-icon>
|
||||
<span>Deposits where this customer is a joint holder</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>Deducted against</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="muted">
|
||||
First-named holder
|
||||
@if (a.deducteeClientId) {
|
||||
(client {{ a.deducteeClientId }})
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Section 3: exempt accounts ── -->
|
||||
@if (exemptAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span>Savings and current accounts</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>TDS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td><span class="f121__pill">Not applicable</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.f121 {
|
||||
--f121-line: #e5e7eb;
|
||||
--f121-bg: #f9fafb;
|
||||
--f121-ink: #111827;
|
||||
--f121-muted: #6b7280;
|
||||
--f121-blue: #1d4ed8;
|
||||
--f121-green: #15803d;
|
||||
--f121-amber: #b45309;
|
||||
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--f121-ink);
|
||||
}
|
||||
|
||||
.f121__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.f121__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.f121__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.f121__controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.f121__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
> span {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: var(--f121-bg);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.f121__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__kpi {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__kpi-n {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.f121__kpi-l {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--f121-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Notes ── */
|
||||
.f121__note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
&--tip {
|
||||
border-left-color: var(--f121-green);
|
||||
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-green);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.f121__card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.f121__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
background: var(--f121-bg);
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__foot {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.f121__empty {
|
||||
padding: 20px 14px;
|
||||
color: var(--f121-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Key/value list ── */
|
||||
.f121__kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
> span:first-child {
|
||||
color: var(--f121-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--muted > span:last-child {
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--warn > span:last-child {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.f121__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.f121__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
color: var(--f121-muted);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__row--warn > td {
|
||||
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.f121__row--total > td {
|
||||
background: var(--f121-bg);
|
||||
border-top: 1px solid var(--f121-line);
|
||||
}
|
||||
|
||||
/* ── Bits ── */
|
||||
.f121__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
& + & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||
color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Domain logic */
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsMode,
|
||||
TdsQuarterLine,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
normaliseFy,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||
export interface Form121AccountRow {
|
||||
accountNo: string;
|
||||
productName: string;
|
||||
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||
kind: string;
|
||||
/** Interest credited to this account inside the selected FY. */
|
||||
interest: number;
|
||||
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||
tdsDeducted: number;
|
||||
/** Whether Fineract is currently withholding tax on this account. */
|
||||
withHoldTax: boolean;
|
||||
taxGroup: string | null;
|
||||
/** When tax on this account settles — from the product, not a preference. */
|
||||
mode: TdsMode;
|
||||
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||
tdsApplicable: boolean;
|
||||
/** This customer is the deductee — false when they are a joint holder only. */
|
||||
isDeductee: boolean;
|
||||
/** Who the tax is deducted against, when it is not this customer. */
|
||||
deducteeClientId: number | null;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form121-tab',
|
||||
templateUrl: './form121-tab.component.html',
|
||||
styleUrls: ['./form121-tab.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
clientId = '';
|
||||
loading = true;
|
||||
loadError = false;
|
||||
|
||||
// ── Customer ──
|
||||
clientName = '';
|
||||
dateOfBirth = '';
|
||||
pan = '';
|
||||
panWellFormed = false;
|
||||
|
||||
// ── Declaration on record ──
|
||||
declarationOnRecord = false;
|
||||
/**
|
||||
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||
* than from the payee's age.
|
||||
*/
|
||||
declarationForm: DeclarationForm = '121';
|
||||
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||
declarationKind: '15G' | '15H' = '15G';
|
||||
declarationFy = '';
|
||||
/**
|
||||
* The KYC step records only the year, not the date the branch accepted the
|
||||
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||
*/
|
||||
acceptanceDateKnown = false;
|
||||
|
||||
eligibility: EligibilityResult | null = null;
|
||||
|
||||
// ── Selection ──
|
||||
availableFys: string[] = [];
|
||||
fy = '';
|
||||
|
||||
// ── Computed ──
|
||||
accounts: Form121AccountRow[] = [];
|
||||
schedule: TdsSchedule | null = null;
|
||||
actualTds = 0;
|
||||
tdsSaved = 0;
|
||||
|
||||
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||
/** Withheld-tax postings by FY, from the ledger. */
|
||||
private actualTdsByFy = new Map<string, number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ────────────────────────────── Loading ──────────────────────────────
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
switchMap(({ client, kyc, accounts }) => {
|
||||
this.applyClient(client);
|
||||
this.applyKyc(kyc);
|
||||
const deposits = this.depositAccountsOf(accounts);
|
||||
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as any[]);
|
||||
})
|
||||
)
|
||||
.subscribe((details: any[]) => {
|
||||
this.loading = false;
|
||||
this.ingest(details.filter(Boolean));
|
||||
this.recompute();
|
||||
});
|
||||
}
|
||||
|
||||
private applyClient(client: any): void {
|
||||
this.clientName = client?.displayName ?? '';
|
||||
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||
}
|
||||
|
||||
private applyKyc(kyc: any): void {
|
||||
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||
this.panWellFormed = isWellFormedPan(this.pan);
|
||||
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||
this.declarationFy = kyc?.form15FY ?? '';
|
||||
this.acceptanceDateKnown = false;
|
||||
}
|
||||
|
||||
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||
private depositAccountsOf(accounts: any): any[] {
|
||||
return (accounts?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
private fetchAccount(a: any): Observable<any> {
|
||||
const id = `${a.id}`;
|
||||
const code = a.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
// One unreadable account must not blank the whole tab.
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns each account payload into an engine input.
|
||||
*
|
||||
* Joint and minor attributes are read defensively: they are captured on the
|
||||
* account stepper but may not surface on every read endpoint, and an absent
|
||||
* field must default to a single-name account rather than throw.
|
||||
*/
|
||||
private ingest(details: any[]): void {
|
||||
this.accountInputs = [];
|
||||
this.actualTdsByFy = new Map<string, number>();
|
||||
const fys = new Set<string>();
|
||||
const clientIdNum = Number(this.clientId);
|
||||
|
||||
for (const d of details) {
|
||||
const code: string = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? `${d.id}`,
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? clientIdNum,
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
let tdsDeducted = 0;
|
||||
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (!iso) {
|
||||
continue;
|
||||
}
|
||||
fys.add(fyOf(iso));
|
||||
|
||||
if (t.transactionType?.interestPosting) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
} else if (t.transactionType?.withholdTax) {
|
||||
const amount = t.amount ?? 0;
|
||||
tdsDeducted += amount;
|
||||
const txnFy = fyOf(iso);
|
||||
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
const deductee = tdsDeducteeOf(ref);
|
||||
const meta: Form121AccountRow = {
|
||||
accountNo: ref.accountNo,
|
||||
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||
kind: code.includes('fixedDeposit')
|
||||
? 'Fixed Deposit'
|
||||
: code.includes('recurringDeposit')
|
||||
? 'Recurring Deposit'
|
||||
: 'Savings',
|
||||
interest: 0,
|
||||
tdsDeducted,
|
||||
withHoldTax: !!d.withHoldTax,
|
||||
taxGroup: d.taxGroup?.name ?? null,
|
||||
mode: tdsModeFor(ref),
|
||||
tdsApplicable: isTdsApplicable(ref),
|
||||
isDeductee: deductee === clientIdNum,
|
||||
deducteeClientId: deductee
|
||||
};
|
||||
|
||||
this.accountInputs.push({ ...ref, credits, meta });
|
||||
}
|
||||
|
||||
this.availableFys = [...fys].sort().reverse();
|
||||
if (!this.availableFys.includes(this.fy)) {
|
||||
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Computation ─────────────────────────────
|
||||
|
||||
/** Re-runs the engine for the selected year. */
|
||||
recompute(): void {
|
||||
this.schedule = null;
|
||||
this.eligibility = null;
|
||||
this.tdsSaved = 0;
|
||||
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||
|
||||
if (!this.fy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const year = normaliseFy(this.fy);
|
||||
|
||||
// Per-account interest for the selected year, for the three lists.
|
||||
for (const a of this.accountInputs) {
|
||||
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||
|
||||
if (!this.dateOfBirth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||
// deposits attributed to the primary holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||
const declaration = this.declarationForSelectedFy();
|
||||
|
||||
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||
|
||||
if (declaration) {
|
||||
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||
}
|
||||
|
||||
// The branch cannot compute income earned outside the bank, so the income
|
||||
// limb is reported as unassessed rather than silently passed.
|
||||
this.eligibility = checkEligibility(ctx, {
|
||||
estimatedInterest: this.schedule.totalInterest,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for this customer overall.
|
||||
*
|
||||
* Timing is per account, but the customer needs one view. A quarterly account
|
||||
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||
* that is the honest headline; the per-account list carries the detail.
|
||||
*/
|
||||
private get overallMode(): TdsMode {
|
||||
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
/** The declaration, but only when it was filed for the year being shown. */
|
||||
private declarationForSelectedFy(): Declaration | null {
|
||||
if (!this.declarationOnRecord || !this.fy) {
|
||||
return null;
|
||||
}
|
||||
const year = normaliseFy(this.fy);
|
||||
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: this.declarationKind,
|
||||
// No acceptance date on record — treated as covering the whole year.
|
||||
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||
fy: year
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── Template helpers ─────────────────────────
|
||||
|
||||
get mode(): TdsMode {
|
||||
return this.schedule?.mode ?? 'QUARTERLY';
|
||||
}
|
||||
|
||||
get age(): number {
|
||||
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||
}
|
||||
|
||||
get formKind(): string {
|
||||
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||
}
|
||||
|
||||
/** The form the payee would file for the selected year. */
|
||||
get formLabel(): string {
|
||||
if (!this.fy) {
|
||||
return '—';
|
||||
}
|
||||
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||
}
|
||||
|
||||
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||
get bandLabel(): string {
|
||||
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision and return references for the selected year. Both changed on
|
||||
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||
*/
|
||||
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||
return {
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
rule: cfg?.declarationRule ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||
get liableAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||
}
|
||||
|
||||
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||
get jointHolderAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||
}
|
||||
|
||||
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||
get exemptAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||
}
|
||||
|
||||
/** Engine figure less what the ledger shows was actually withheld. */
|
||||
get variance(): number {
|
||||
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||
}
|
||||
|
||||
/** How much more interest before TDS starts. */
|
||||
get headroom(): number {
|
||||
if (!this.schedule) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||
}
|
||||
|
||||
get thresholdCrossed(): boolean {
|
||||
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration is on record but deposit accounts are still set to withhold —
|
||||
* the branch has not switched them off.
|
||||
*/
|
||||
get declarationNotAppliedToAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer filed a declaration but holds joint deposits where someone else
|
||||
* is the deductee — their form does nothing for those.
|
||||
*/
|
||||
get declarationUselessOnJointAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||
}
|
||||
|
||||
/** Worth prompting the branch to collect a declaration. */
|
||||
get shouldSuggestDeclaration(): boolean {
|
||||
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||
}
|
||||
|
||||
trackLine(_: number, line: TdsQuarterLine): number {
|
||||
return line.quarter;
|
||||
}
|
||||
|
||||
trackAccount(_: number, account: Form121AccountRow): string {
|
||||
return account.accountNo;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,32 @@
|
||||
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@if (!isEditMode && kycData) {
|
||||
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||
<div
|
||||
class="kyc-exception-banner"
|
||||
style="
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
|
||||
<div>
|
||||
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
|
||||
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
|
||||
Account is open under a temporary KYC Exception. Reason:
|
||||
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
|
||||
>. Please contact the customer and complete verification as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Table -->
|
||||
<div class="kv-table">
|
||||
<div class="kv-table__header">
|
||||
@@ -740,6 +766,32 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="full-span exception-controls-row"
|
||||
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
|
||||
>
|
||||
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed"
|
||||
>Enterprise PAN Verification Failed</mat-option
|
||||
>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="full-span">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
'',
|
||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||
],
|
||||
remarks: ['']
|
||||
remarks: [''],
|
||||
isException: [false],
|
||||
exceptionReason: [[]]
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
aadhaarNumber: '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
remarks: data.remarks || ''
|
||||
remarks: data.remarks || '',
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||
*
|
||||
* A field counts as verified if it was verified in this session OR is already verified on the
|
||||
* stored record; either way the customer has cleared that check. Verifying one of two
|
||||
* outstanding items narrows the exception to the remaining one rather than closing it.
|
||||
*/
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
|
||||
return deriveKycExceptionFields({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: !this.isNonIndividual,
|
||||
hadException: this.kycData?.isException === true,
|
||||
previousReason: this.kycData?.exceptionReason || null,
|
||||
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
||||
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
||||
});
|
||||
}
|
||||
|
||||
saveKyc(): void {
|
||||
if (this.kycForm.invalid) {
|
||||
return;
|
||||
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
if (!this.kycId) {
|
||||
@@ -244,6 +274,38 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
||||
if (this.isEditMode) {
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
||||
next: () => {
|
||||
this.loadKycData();
|
||||
callback();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying[field] = false;
|
||||
this.verificationStatus[field] = 'FAILED';
|
||||
this.verificationError[field] =
|
||||
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
verifyPAN(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
@@ -254,31 +316,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.pan = null;
|
||||
this.verificationDetails.pan = null;
|
||||
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
this.verificationError.pan =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 'pan');
|
||||
}
|
||||
|
||||
/** Show Aadhaar input in summary view for re-verification */
|
||||
@@ -305,31 +369,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.aadhaarOtpSent = false;
|
||||
this.aadhaarOtp = '';
|
||||
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
}, 'aadhaar');
|
||||
}
|
||||
|
||||
/** Step 2: Verify OTP entered by user */
|
||||
@@ -380,31 +446,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.gstin = null;
|
||||
this.verificationDetails.gstin = null;
|
||||
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 'gstin');
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
||||
ClientPreviewStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientFamilyMemberDialogComponent,
|
||||
ClientFamilyMemberChildDialogComponent,
|
||||
CaptureImageDialogComponent,
|
||||
AddClientCollateralComponent,
|
||||
ClientDatatableStepComponent,
|
||||
|
||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
|
||||
* Only populated after the Checker has approved and the send succeeded. */
|
||||
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
|
||||
return this.http.get<{
|
||||
emailSubject: string | null;
|
||||
emailBody: string | null;
|
||||
emailSent: boolean;
|
||||
emailSentAt: string | null;
|
||||
emailOpenedAt: string | null;
|
||||
emailFrom: string | null;
|
||||
emailTo: string | null;
|
||||
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
|
||||
}
|
||||
|
||||
getClientDataAndTemplate(clientId: string) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
@@ -958,4 +972,33 @@ export class ClientsService {
|
||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||
}
|
||||
|
||||
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||
}
|
||||
|
||||
getKycExceptions(resolved = false): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
|
||||
* WHATSAPP channels — actually transmits the notice.
|
||||
*
|
||||
* The destination is resolved server-side from the client record; it is deliberately not part of
|
||||
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
|
||||
*
|
||||
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
|
||||
* response rather than assuming success, since the log row is written either way.
|
||||
*/
|
||||
saveKycContactLog(
|
||||
clientId: string | number,
|
||||
data: { channel: string; notes: string; officerName?: string }
|
||||
): Observable<any> {
|
||||
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||
}
|
||||
|
||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,8 +430,22 @@ export class CreateClientComponent {
|
||||
}
|
||||
this.validationError = '';
|
||||
// Save KYC
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: (res: any) => {
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService
|
||||
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
// Upload photo & signature
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
@@ -696,12 +710,20 @@ export class CreateClientComponent {
|
||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(clientId);
|
||||
|
||||
// Save KYC only if it wasn't already created during the KYC step
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
// Save/update KYC to ensure latest exception fields are persisted
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
navigateToClient();
|
||||
}
|
||||
|
||||
@@ -84,14 +84,70 @@
|
||||
<mat-icon>phone_iphone</mat-icon>
|
||||
<span>Mobile Number</span>
|
||||
</div>
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||
<mat-checkbox
|
||||
[checked]="oldMobileNotAccessible"
|
||||
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||
color="primary"
|
||||
>
|
||||
Old mobile number not accessible (lost / deactivated SIM)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="contact-verify-panel__grid">
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
@if (!oldMobileNotAccessible) {
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
} @else {
|
||||
<div
|
||||
class="fir-upload-box"
|
||||
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||
>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<input
|
||||
type="file"
|
||||
(change)="onFirFileSelected($event)"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
#firFileInput
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="firFileInput.click()"
|
||||
[disabled]="uploadingFir"
|
||||
>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||
</button>
|
||||
@if (uploadingFir) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||
}
|
||||
@if (firDocumentId && !uploadingFir) {
|
||||
<span
|
||||
style="
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||
Uploaded Successfully
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
||||
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatDivider,
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
||||
newMobileOtpId: number | null = null;
|
||||
oldMobileVerified = false;
|
||||
newMobileVerified = false;
|
||||
|
||||
// FIR exception flow properties for inaccessible old mobile number
|
||||
oldMobileNotAccessible = false;
|
||||
firFileName = '';
|
||||
firDocumentId: number | null = null;
|
||||
uploadingFir = false;
|
||||
|
||||
oldEmailOtpId: number | null = null;
|
||||
newEmailOtpId: number | null = null;
|
||||
oldEmailVerified = false;
|
||||
@@ -215,11 +224,15 @@ export class EditClientComponent {
|
||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
}
|
||||
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified
|
||||
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||
get contactVerificationComplete(): boolean {
|
||||
const changes = this.contactChanges;
|
||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
||||
return false;
|
||||
if (changes.mobileChanged) {
|
||||
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||
return false;
|
||||
@@ -227,6 +240,43 @@ export class EditClientComponent {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||
if (checked !== undefined) {
|
||||
this.oldMobileNotAccessible = checked;
|
||||
}
|
||||
if (this.oldMobileNotAccessible) {
|
||||
this.oldMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||
onFirFileSelected(event: any): void {
|
||||
const file: File = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
this.firFileName = file.name;
|
||||
this.uploadingFir = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'FIR - Lost Mobile Number');
|
||||
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||
|
||||
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||
next: (res: any) => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = null;
|
||||
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onOldMobileOtpState(state: ContactOtpState): void {
|
||||
this.oldMobileOtpId = state.otpId;
|
||||
this.oldMobileVerified = state.verified;
|
||||
@@ -254,6 +304,10 @@ export class EditClientComponent {
|
||||
this.newMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
this.newMobileVerified = false;
|
||||
this.oldMobileNotAccessible = false;
|
||||
this.firFileName = '';
|
||||
this.firDocumentId = null;
|
||||
this.uploadingFir = false;
|
||||
this.oldEmailOtpId = null;
|
||||
this.newEmailOtpId = null;
|
||||
this.oldEmailVerified = false;
|
||||
@@ -439,7 +493,11 @@ export class EditClientComponent {
|
||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||
// state fields for the full naming rationale.
|
||||
if (changedFields.mobileChanged) {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||
} else {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
}
|
||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
@@ -630,16 +688,36 @@ export class EditClientComponent {
|
||||
|
||||
private detectSensitiveFieldChanges(originalData: any) {
|
||||
const form = this.clientGeneralForm;
|
||||
if (!form || !originalData) {
|
||||
return {
|
||||
nameChanged: false,
|
||||
mobileChanged: false,
|
||||
emailChanged: false,
|
||||
dobChanged: false,
|
||||
oldName: '',
|
||||
newName: '',
|
||||
oldMobile: '',
|
||||
newMobile: '',
|
||||
oldEmail: '',
|
||||
newEmail: '',
|
||||
oldDob: '',
|
||||
newDob: ''
|
||||
};
|
||||
}
|
||||
|
||||
const mobileControl = form.get('mobileNo');
|
||||
const emailControl = form.get('emailAddress');
|
||||
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
||||
// customer's email casing into the saved record or the OTP destination.
|
||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
||||
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||
// customer's saved value or the OTP destination.
|
||||
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||
const newEmail = (emailControl?.value || '').toString().trim();
|
||||
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||
@@ -647,8 +725,8 @@ export class EditClientComponent {
|
||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||
|
||||
const oldMobile = (originalData.mobileNo || '').trim();
|
||||
const oldEmail = (originalData.emailAddress || '').trim();
|
||||
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||
const oldDobRaw = originalData.dateOfBirth;
|
||||
const oldDob = Array.isArray(oldDobRaw)
|
||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||
@@ -681,12 +759,25 @@ export class EditClientComponent {
|
||||
fullname: originalData.fullname || ''
|
||||
});
|
||||
|
||||
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||
const newMobileNorm = normPhone(newMobile);
|
||||
const oldMobileNorm = normPhone(oldMobile);
|
||||
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
||||
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
||||
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||
const mobileChanged =
|
||||
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
const emailChanged =
|
||||
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
mobileChanged: newMobile !== oldMobile,
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
||||
mobileChanged,
|
||||
emailChanged,
|
||||
dobChanged: newDob !== oldDob,
|
||||
oldName,
|
||||
newName,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycExceptionFields,
|
||||
KycExceptionInput,
|
||||
KYC_EXCEPTION_AADHAAR,
|
||||
KYC_EXCEPTION_PAN
|
||||
} from './kyc-exception.util';
|
||||
|
||||
const TODAY = '05 August 2026';
|
||||
|
||||
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
/** Individual client that already carries an open exception. */
|
||||
const existingException = (
|
||||
panVerified: boolean,
|
||||
aadhaarVerified: boolean,
|
||||
previousReason: string
|
||||
): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: true,
|
||||
previousReason,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
describe('deriveKycExceptionFields', () => {
|
||||
describe('at client creation', () => {
|
||||
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
panVerified: true,
|
||||
aadhaarVerified: false,
|
||||
aadhaarApplicable: false,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
}).isException
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: both,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the other way round too', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opening', () => {
|
||||
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||
// and a brand-new exception appeared as already closed.
|
||||
const result = deriveKycExceptionFields({
|
||||
panVerified: false,
|
||||
aadhaarVerified: true,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: 'PAN Card Verification Failed',
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
expect(result.exceptionResolved).toBe(false);
|
||||
expect(result.exceptionResolvedOn).toBeNull();
|
||||
expect(result.isException).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('officer-entered reasons', () => {
|
||||
it('keeps a manual reason alongside the automatic ones', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: ['Address proof pending']
|
||||
});
|
||||
expect(result).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: 'Address proof pending',
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||
// verified, so it must not survive into the payload.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives a client's KYC exception state from their verification status.
|
||||
*
|
||||
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||
* and Aadhaar are verified at the moment of the save.
|
||||
*
|
||||
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||
*/
|
||||
|
||||
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||
|
||||
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||
KYC_EXCEPTION_PAN,
|
||||
KYC_EXCEPTION_AADHAAR
|
||||
];
|
||||
|
||||
export interface KycExceptionInput {
|
||||
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||
panVerified: boolean;
|
||||
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||
aadhaarVerified: boolean;
|
||||
/**
|
||||
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||
*/
|
||||
aadhaarApplicable: boolean;
|
||||
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||
hadException: boolean;
|
||||
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||
previousReason: string | null;
|
||||
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||
manualReasons?: string[];
|
||||
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||
todayFormatted: string;
|
||||
}
|
||||
|
||||
export interface KycExceptionFields {
|
||||
isException: boolean;
|
||||
exceptionReason: string | null;
|
||||
exceptionResolved: boolean;
|
||||
exceptionResolvedOn: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps verification status to the four exception columns the API expects.
|
||||
*
|
||||
* Three outcomes:
|
||||
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||
* why the exception was ever raised.
|
||||
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||
* in either tab.
|
||||
*/
|
||||
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||
const outstanding: string[] = [];
|
||||
|
||||
if (!input.panVerified) {
|
||||
outstanding.push(KYC_EXCEPTION_PAN);
|
||||
}
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified) {
|
||||
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||
}
|
||||
|
||||
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||
for (const reason of input.manualReasons || []) {
|
||||
const trimmed = (reason || '').trim();
|
||||
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||
outstanding.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (outstanding.length > 0) {
|
||||
return {
|
||||
isException: true,
|
||||
exceptionReason: outstanding.join(', '),
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hadException) {
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: input.previousReason || null,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: input.todayFormatted
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
@@ -99,6 +99,30 @@
|
||||
</svg>
|
||||
My Disbursements
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
|
||||
[routerLink]="['/clerk/share-withdrawal-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path
|
||||
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
|
||||
/>
|
||||
</svg>
|
||||
My Share Requests
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
|
||||
[routerLink]="['/clerk/share-closure-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="11" width="16" height="9" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
My Share Closures
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
@@ -350,6 +374,10 @@
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -442,6 +470,10 @@
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
@@ -583,6 +615,15 @@
|
||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
@@ -1271,7 +1312,7 @@
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Reports</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
||||
@@ -1331,6 +1372,39 @@
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
|
||||
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
|
||||
<!-- Loan Origination -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Loan Origination</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Reports</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
tabindex="0"
|
||||
routerLink="/loan-origination/pipeline"
|
||||
[queryParams]="{ view: 'channel' }"
|
||||
>
|
||||
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
|
||||
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
|
||||
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item routerLink="/ckyc" tabindex="0">
|
||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
|
||||
@@ -1359,27 +1433,27 @@
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Core & Books</div>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/voucher">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
|
||||
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Voucher Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/sundry-dr-cr">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
|
||||
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Sundry Dr / Cr Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/pnl-impact">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
P&L Impact Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/gl-reconciliation">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
|
||||
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
GL Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/fixed-assets">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
|
||||
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Fixed Assets Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/inter-company">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
|
||||
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Inter-Company Reconciliation
|
||||
</button>
|
||||
@@ -1388,19 +1462,19 @@
|
||||
<!-- Bank & Clearing -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Bank & Clearing</div>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/bank-reco">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
|
||||
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/api-bank-reco">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
|
||||
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
API Based Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/neft-rtgs">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
|
||||
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NEFT / RTGS Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/clearing-reco">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Clearing Reconciliation
|
||||
</button>
|
||||
@@ -1409,15 +1483,15 @@
|
||||
<!-- Digital / Payments -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Digital & Payments</div>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/udir">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
|
||||
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
UDIR (IMPS / UPI / ATM)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/three-way-digital">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
|
||||
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Three-way Digital Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/nach-reco">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
|
||||
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NACH Reconciliation
|
||||
</button>
|
||||
@@ -1426,11 +1500,11 @@
|
||||
<!-- Instruments & Members -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Instruments & Members</div>
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0" class="menu-item-live">
|
||||
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
DD / Pay Order Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/share-member">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Share Member Reconciliation
|
||||
</button>
|
||||
@@ -1439,11 +1513,11 @@
|
||||
<!-- Compliance & Financials -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Compliance & Financials</div>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/invoice-payment">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Invoice Payment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/tax-reco">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
|
||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Tax Reconciliation
|
||||
</button>
|
||||
@@ -1561,6 +1635,14 @@
|
||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &
|
||||
TDS
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
|
||||
Govt
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||
Hypothecation Certificate
|
||||
@@ -1762,6 +1844,14 @@
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
|
||||
routerLink="/reports/risk-review"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
|
||||
</a>
|
||||
</div>
|
||||
<!-- OSS Reports -->
|
||||
<div class="gl-group">
|
||||
|
||||
@@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
/** True on the manager's full-screen withdrawal-request review screen. */
|
||||
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
|
||||
get isWithdrawalReviewUrl(): boolean {
|
||||
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
}
|
||||
|
||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { BRANCHES, BranchMaster } from '../npa/npa-data';
|
||||
|
||||
export { BRANCHES, BranchMaster };
|
||||
|
||||
/**
|
||||
* Sample FIU-IND data — schema mirrors the FINnet 2.0 CSV submission templates
|
||||
* (TC1, TC2, KC1, KC2, Account_Detail, Account_Person_Relation).
|
||||
@@ -155,6 +159,25 @@ export const FIU_BANK = {
|
||||
cbwtrThreshold: 500000 // CBWTR threshold ₹5 L
|
||||
};
|
||||
|
||||
/**
|
||||
* CTR/STR records below use the bank's own internal numeric branch codes (as FINnet's TC2
|
||||
* schema expects), not the app-wide BranchMaster codes (BR01, BR02, ...) every other module
|
||||
* scopes Head-Office-vs-branch access by. This maps one to the other so a branch-level login's
|
||||
* officeName can be matched consistently, the same way NPA/Reconciliation/Loan Origination do.
|
||||
*/
|
||||
export const FIU_BRANCH_CODE_TO_CANONICAL: Record<string, string> = {
|
||||
'1': 'BR01',
|
||||
'2': 'BR02',
|
||||
'3': 'BR03',
|
||||
'4': 'BR04',
|
||||
'5': 'BR05',
|
||||
'6': 'BR06',
|
||||
'8': 'BR07',
|
||||
'9': 'BR08',
|
||||
'10': 'BR02',
|
||||
'12': 'BR04'
|
||||
};
|
||||
|
||||
/**
|
||||
* ── CTR transactions (FINnet TC2-style) ──
|
||||
* Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000
|
||||
@@ -297,6 +320,316 @@ export const CTR_TXNS: CtrTransaction[] = [
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* ── TC1: ATM / card transactions ──
|
||||
*
|
||||
* TC2 carries what happened at the counter; TC1 carries what happened at the
|
||||
* card rails. Both feed the same CTR, because an ATM cash withdrawal is still
|
||||
* a cash transaction and counts towards the customer's monthly aggregate.
|
||||
*
|
||||
* Every row here belongs to an account that is already in CTR_TXNS and carries
|
||||
* the same reportRefNo, so the six FINnet files reconcile against each other.
|
||||
*/
|
||||
export interface CardTransaction {
|
||||
reportType: ReportType;
|
||||
reportRefNo: number;
|
||||
branchCode: string;
|
||||
accountNumber: string;
|
||||
/** FINnet account-type code, same codes used in Account_Detail. */
|
||||
accountType: string;
|
||||
relationshipFlag: 'Customer' | 'Non-Customer';
|
||||
/** Settled at the switch, or declined / reversed. */
|
||||
txnStatus: 'Success' | 'Failed' | 'Reversed';
|
||||
txnId: number;
|
||||
txnDate: string;
|
||||
txnTime: string;
|
||||
depositOrWithdrawal: 'Deposit' | 'Withdrawal';
|
||||
txnAmount: number;
|
||||
cardType: 'RuPay Debit' | 'VISA Debit' | 'Mastercard Debit';
|
||||
/** Masked to the last four digits — the PAN itself is never put in the file. */
|
||||
cardNumber: string;
|
||||
/** FINnet country code; 95 is India, as used across the KC files. */
|
||||
cardIssuingCountry: string;
|
||||
cardIssuingBank: string;
|
||||
/** NFS / NPCI switch reference for the leg. */
|
||||
switchTxnId: string;
|
||||
atmId: string;
|
||||
/** UI convenience, not part of the TC1 schema. */
|
||||
customerName?: string;
|
||||
}
|
||||
|
||||
export const TC1_TXNS: CardTransaction[] = [
|
||||
// Ref 1 — ANAND KRISHNAN: routine ATM usage alongside a large counter deposit.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 1,
|
||||
branchCode: '1',
|
||||
accountNumber: '110001101000838',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9001,
|
||||
txnDate: '06-01-2026',
|
||||
txnTime: '19:42:11',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 20000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX4417',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010600418',
|
||||
atmId: 'NEXA0001',
|
||||
customerName: 'ANAND KRISHNAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 1,
|
||||
branchCode: '1',
|
||||
accountNumber: '110001101000838',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9002,
|
||||
txnDate: '18-01-2026',
|
||||
txnTime: '11:07:53',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 25000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX4417',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011800911',
|
||||
atmId: 'NEXA0003',
|
||||
customerName: 'ANAND KRISHNAN'
|
||||
},
|
||||
|
||||
// Ref 2 — MEERA SUNDARAM: card cash-out at another bank's ATM.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 2,
|
||||
branchCode: '3',
|
||||
accountNumber: '110001204000712',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9003,
|
||||
txnDate: '09-01-2026',
|
||||
txnTime: '20:15:36',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 10000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX8820',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010901277',
|
||||
atmId: 'SBIN004512',
|
||||
customerName: 'MEERA SUNDARAM'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 2,
|
||||
branchCode: '3',
|
||||
accountNumber: '110001204000712',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
// Declined for insufficient balance. Failed legs are still reported: the
|
||||
// attempt itself is part of the customer's pattern.
|
||||
txnStatus: 'Failed',
|
||||
txnId: 9004,
|
||||
txnDate: '09-01-2026',
|
||||
txnTime: '20:17:02',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 20000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX8820',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010901284',
|
||||
atmId: 'SBIN004512',
|
||||
customerName: 'MEERA SUNDARAM'
|
||||
},
|
||||
|
||||
// Ref 3 — HARPREET SINGH BEDI: cash recycler deposit through the card.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 3,
|
||||
branchCode: '6',
|
||||
accountNumber: '110001307000455',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9005,
|
||||
txnDate: '12-01-2026',
|
||||
txnTime: '13:28:44',
|
||||
depositOrWithdrawal: 'Deposit',
|
||||
txnAmount: 49500,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX6193',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011200630',
|
||||
atmId: 'NEXA0002-CRM',
|
||||
customerName: 'HARPREET SINGH BEDI'
|
||||
},
|
||||
|
||||
// Ref 4 — FAROOQ ABDULLAH KHAN: repeated near-limit withdrawals in one day.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9006,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:03:19',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600142',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9007,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:06:41',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600149',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
// Dispensed short and auto-reversed by the switch the same evening.
|
||||
txnStatus: 'Reversed',
|
||||
txnId: 9008,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:09:05',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600155',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
|
||||
// Ref 5 — DEEPIKA RANGANATHAN
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 5,
|
||||
branchCode: '9',
|
||||
accountNumber: '110001509000268',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9009,
|
||||
txnDate: '21-01-2026',
|
||||
txnTime: '17:51:28',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 15000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX9354',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012101508',
|
||||
atmId: 'HDFC009871',
|
||||
customerName: 'DEEPIKA RANGANATHAN'
|
||||
},
|
||||
|
||||
// Ref 6 — RAJENDRA PRASAD YADAV
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 6,
|
||||
branchCode: '5',
|
||||
accountNumber: '110001610000841',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9010,
|
||||
txnDate: '23-01-2026',
|
||||
txnTime: '10:34:12',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 40000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX1268',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012300287',
|
||||
atmId: 'NEXA0005',
|
||||
customerName: 'RAJENDRA PRASAD YADAV'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 6,
|
||||
branchCode: '5',
|
||||
accountNumber: '110001610000841',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9011,
|
||||
txnDate: '28-01-2026',
|
||||
txnTime: '18:22:47',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 35000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX1268',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012801733',
|
||||
atmId: 'NEXA0005',
|
||||
customerName: 'RAJENDRA PRASAD YADAV'
|
||||
},
|
||||
|
||||
// Ref 7 — SHABANA TARIQ MIRZA: the account's card is used by a third party,
|
||||
// which is why the relationship flag differs from the rest.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 7,
|
||||
branchCode: '12',
|
||||
accountNumber: '110001711000527',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Non-Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9012,
|
||||
txnDate: '27-01-2026',
|
||||
txnTime: '21:08:33',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 30000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX7741',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012702094',
|
||||
atmId: 'ICIC007233',
|
||||
customerName: 'SHABANA TARIQ MIRZA'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* ── STR cases ──
|
||||
* Raised either by the AML rules engine or by an officer flagging a CTR row as
|
||||
|
||||
+167
-246
@@ -14,12 +14,25 @@
|
||||
<div>
|
||||
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
|
||||
<div class="fiu-head-s">
|
||||
{{ bank.name }} · {{ bank.branch }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||
{{ bank.name }} · {{ scopeName }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||
· PMLA 2002 / FINnet 2.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-head-meta">
|
||||
<div>
|
||||
<span class="fiu-meta-l">Branch</span>
|
||||
@if (isHeadOffice) {
|
||||
<select class="fiu-inp" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
|
||||
<option value="">All Branches</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="fiu-meta-v">{{ scopeName }}</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
|
||||
</div>
|
||||
@@ -96,6 +109,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WHEN TO REPORT — the five PMLA obligations -->
|
||||
<div class="ob-block">
|
||||
<div class="ob-head">
|
||||
<div>
|
||||
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
|
||||
<p class="ob-sub">
|
||||
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
|
||||
electronically through the FINnet 2.0 portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-grid">
|
||||
@for (o of obligations; track o.code) {
|
||||
<div class="ob-card" [class.ob-card--off]="!o.handled">
|
||||
<div class="ob-card-h">
|
||||
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
|
||||
<span class="ob-code">{{ o.code }}</span>
|
||||
<span class="ob-name">{{ o.name }}</span>
|
||||
@if (o.handled) {
|
||||
<span class="ob-flag ob-flag--on">Filed here</span>
|
||||
} @else {
|
||||
<span class="ob-flag">Tracked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Report when</label><span>{{ o.trigger }}</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
|
||||
</div>
|
||||
<div class="ob-note">{{ o.note }}</div>
|
||||
@if (o.code === 'CTR') {
|
||||
<button class="dc-btn" (click)="showTab('ctr')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
|
||||
</button>
|
||||
} @else if (o.code === 'STR') {
|
||||
<button class="dc-btn" (click)="showTab('str')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open STR
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance cards -->
|
||||
<div class="fiu-compliance">
|
||||
<div class="dc-card">
|
||||
@@ -136,87 +195,106 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related CTR threshold alerts -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live CTR Threshold Alerts</h3>
|
||||
<p>
|
||||
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
|
||||
evidenced, send the transaction to STR for the Principal Officer to investigate.
|
||||
</p>
|
||||
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
|
||||
<div class="seq-block">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR Submission Files — FINnet 2.0</h3>
|
||||
<p>
|
||||
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||
with the same bundle and are worked on the Customer Cards tab.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||
</button>
|
||||
</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 class="ta-c">Routes To</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of ctrAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(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-xml" (click)="escalateAlert(alert)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No live alerts.</td>
|
||||
</tr>
|
||||
|
||||
<!-- Scope: these controls decide which transactions the six files carry. -->
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Step rail -->
|
||||
<div class="seq-rail">
|
||||
@for (f of finnetSequence; track f.key) {
|
||||
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
|
||||
<span class="seq-n">{{ f.step }}</span>
|
||||
<span class="seq-txt">
|
||||
<strong>{{ f.name }}</strong>
|
||||
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
|
||||
</span>
|
||||
</button>
|
||||
@if (!$last) {
|
||||
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Open file -->
|
||||
<div class="seq-panel">
|
||||
<div class="seq-panel-h">
|
||||
<div>
|
||||
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
|
||||
<p>{{ openFinnetFile.purpose }}</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
|
||||
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="seq-tbl-wrap">
|
||||
<table class="seq-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="seq-rownum">#</th>
|
||||
@for (h of openFinnetFile.table.headers; track $index) {
|
||||
<th>{{ h }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of openFinnetFile.table.rows; track $index) {
|
||||
<tr>
|
||||
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||
@for (c of r; track $index) {
|
||||
<td>{{ c }}</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||
@if (openFinnetFile.key === 'tc1') {
|
||||
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||
still submitted as a header-only file — that is what FIU-IND expects.
|
||||
} @else {
|
||||
No rows for the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> cash breach awaiting review ·
|
||||
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
|
||||
Principal Officer, who decides there whether to whitelist it or file an STR
|
||||
</p>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
<div class="fiu-form-meta">
|
||||
@@ -264,173 +342,6 @@
|
||||
</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 ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">Ref</th>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th class="ta-c">Type</th>
|
||||
<th>Date / Time</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Instrument</th>
|
||||
<th>Branch</th>
|
||||
<th class="ta-c">Relationship</th>
|
||||
<th class="ta-c">Filed Under</th>
|
||||
<th>Compliance Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredCtr; track t.txnId) {
|
||||
<tr>
|
||||
<td class="ta-c">{{ t.reportRefNo }}</td>
|
||||
<td class="mono">{{ t.accountNumber }}</td>
|
||||
<td>{{ t.customerName }}</td>
|
||||
<td class="ta-c">
|
||||
<span
|
||||
class="dir-pill"
|
||||
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
|
||||
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
|
||||
>
|
||||
{{ t.depositOrWithdrawal }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>₹{{ t.txnAmount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ t.branchCode }}</td>
|
||||
<td class="ta-c">
|
||||
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
|
||||
t.relationshipFlag
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span
|
||||
class="report-pill report-str"
|
||||
title="Also under investigation as an STR case — the transaction remains in the CTR file"
|
||||
>+ STR</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span class="status-pill status-escalated">REFERRED TO STR</span>
|
||||
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab →</button>
|
||||
} @else if (t.complianceStatus === 'EVIDENCED') {
|
||||
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
|
||||
<div class="muted" style="margin-top: 3px">
|
||||
{{ t.evidenceDocType }}
|
||||
@if (t.evidenceReference) {
|
||||
<span> · {{ t.evidenceReference }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
|
||||
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Inline evidence capture for this transaction -->
|
||||
@if (evidencingTxn?.txnId === t.txnId) {
|
||||
<tr class="evidence-row">
|
||||
<td colspan="12">
|
||||
<div class="evidence-panel">
|
||||
<div class="evidence-t">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Record source-of-funds evidence for {{ t.customerName }} — ₹{{
|
||||
t.txnAmount | number: '1.0-0'
|
||||
}}
|
||||
</div>
|
||||
<div class="evidence-grid">
|
||||
<div class="form-row">
|
||||
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
|
||||
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
|
||||
<option value="">Select document…</option>
|
||||
@for (d of evidenceDocTypes; track d) {
|
||||
<option [value]="d">{{ d }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
|
||||
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
|
||||
Confirm — CTR only
|
||||
</button>
|
||||
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="evidence-note">
|
||||
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
|
||||
in the CTR either way — this simply confirms no STR referral is needed.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
|
||||
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR ·
|
||||
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
|
||||
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
|
||||
transaction stays in the CTR file as well.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -443,6 +354,13 @@
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</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">
|
||||
@@ -779,6 +697,9 @@
|
||||
<div>
|
||||
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="str-narrative">
|
||||
<label>Narrative</label>
|
||||
|
||||
@@ -1549,3 +1549,301 @@
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
|
||||
.ob-block {
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.ob-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ob-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ob-sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.ob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ob-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 11px;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
/* Obligations the module only tracks read back a shade quieter than the
|
||||
two it actually files, so the distinction is visible at a glance. */
|
||||
&--off {
|
||||
border-left-color: #cbd5e1;
|
||||
background: #fcfcfd;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ob-ic {
|
||||
color: #1a237e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ob-code {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ob-name {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-flag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
|
||||
&--on {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-due {
|
||||
font-weight: 700;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.ob-note {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ═══ FINNET FILE SEQUENCE ═══ */
|
||||
.seq-block {
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.seq-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.seq-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
|
||||
.seq-n {
|
||||
background: #fff;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.seq-txt strong,
|
||||
.seq-txt em {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seq-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 21px;
|
||||
}
|
||||
|
||||
.seq-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
|
||||
strong {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seq-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seq-panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-tbl-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.seq-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #eef2f7;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-rownum {
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seq-tbl thead .seq-rownum {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.seq-empty {
|
||||
padding: 36px !important;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-family: inherit !important;
|
||||
white-space: normal;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
+371
-43
@@ -13,9 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
CardTransaction,
|
||||
CtrTransaction,
|
||||
CTR_TXNS,
|
||||
TC1_TXNS,
|
||||
FIU_BANK,
|
||||
FIU_BRANCH_CODE_TO_CANONICAL,
|
||||
KC1_INDIVIDUALS,
|
||||
KC2_ENTITIES,
|
||||
KycEntity,
|
||||
@@ -26,6 +31,90 @@ import {
|
||||
STR_TXNS
|
||||
} from './fiu-data';
|
||||
|
||||
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
|
||||
export interface FinnetTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface FinnetFile {
|
||||
key: string;
|
||||
/** File name without the extension, as FIU-IND names it. */
|
||||
name: string;
|
||||
/** Position in the prescribed submission order. */
|
||||
step: number;
|
||||
title: string;
|
||||
purpose: string;
|
||||
table: FinnetTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five reports a reporting entity owes FIU-IND under the PMLA, with the
|
||||
* trigger for each. All of them are filed electronically through FINnet 2.0.
|
||||
*/
|
||||
export interface FiuObligation {
|
||||
code: string;
|
||||
name: string;
|
||||
trigger: string;
|
||||
deadline: string;
|
||||
icon: string;
|
||||
/** Whether this module currently files the report or only tracks the rule. */
|
||||
handled: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const FIU_OBLIGATIONS: FiuObligation[] = [
|
||||
{
|
||||
code: 'CTR',
|
||||
name: 'Cash Transaction Report',
|
||||
trigger:
|
||||
'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'coins',
|
||||
handled: true,
|
||||
note: 'Filed from the CTR tab as the six-file FINnet bundle.'
|
||||
},
|
||||
{
|
||||
code: 'STR',
|
||||
name: 'Suspicious Transaction Report',
|
||||
trigger:
|
||||
'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.',
|
||||
deadline: 'Within 7 working days of forming the suspicion',
|
||||
icon: 'exclamation-triangle',
|
||||
handled: true,
|
||||
note: 'Filed from the STR tab as FINnet STR XML.'
|
||||
},
|
||||
{
|
||||
code: 'CBWTR',
|
||||
name: 'Cross-Border Wire Transfer Report',
|
||||
trigger:
|
||||
'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'globe',
|
||||
handled: false,
|
||||
note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.'
|
||||
},
|
||||
{
|
||||
code: 'CCR',
|
||||
name: 'Counterfeit Currency Report',
|
||||
trigger:
|
||||
'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'money-bill-wave',
|
||||
handled: false,
|
||||
note: 'Raised from the branch counterfeit-note register; not yet wired into this module.'
|
||||
},
|
||||
{
|
||||
code: 'NTR',
|
||||
name: 'Non-Profit Organisation Transaction Report',
|
||||
trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'hand-holding-heart',
|
||||
handled: false,
|
||||
note: 'Depends on NPO flagging in the customer master; not yet wired into this module.'
|
||||
}
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-fiu',
|
||||
templateUrl: './fiu.component.html',
|
||||
@@ -45,12 +134,29 @@ export class FiuComponent implements OnInit {
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
|
||||
/** The five PMLA reporting obligations, shown on the FIU landing tab. */
|
||||
readonly obligations = FIU_OBLIGATIONS;
|
||||
|
||||
bank = FIU_BANK;
|
||||
ctrTxns: CtrTransaction[] = CTR_TXNS;
|
||||
tc1Txns: CardTransaction[] = TC1_TXNS;
|
||||
strTxns: StrTransaction[] = STR_TXNS;
|
||||
kc1: KycIndividual[] = KC1_INDIVIDUALS;
|
||||
kc2: KycEntity[] = KC2_ENTITIES;
|
||||
|
||||
// -------------------- Head Office vs Branch scope --------------------
|
||||
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
|
||||
// bank, not per branch), but the underlying cash transactions a branch's own staff work
|
||||
// (evidence capture, referral) are still branch-attributable — scoped the same way every other
|
||||
// report in the app splits Head Office (sees everything, can drill into one branch) vs a
|
||||
// branch-level login (locked to its own branch).
|
||||
readonly branches = BRANCHES;
|
||||
isHeadOffice = false;
|
||||
currentOfficeName = 'Branch Office';
|
||||
private ownBranchCode: string | null = null;
|
||||
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
|
||||
branchFilter = '';
|
||||
|
||||
/** CTR filters */
|
||||
ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = '';
|
||||
ctrFilterAccount = '';
|
||||
@@ -223,6 +329,18 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const creds = this.authenticationService.getCredentials();
|
||||
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
if (this.isHeadOffice) {
|
||||
this.branchFilter = '';
|
||||
} else {
|
||||
this.ownBranchCode =
|
||||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||||
?.code || null;
|
||||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||
}
|
||||
|
||||
this.loadAmlAlerts();
|
||||
this.loadAmlCtrReports();
|
||||
this.loadAmlStrCases();
|
||||
@@ -614,21 +732,21 @@ export class FiuComponent implements OnInit {
|
||||
|
||||
// -------------------- KPIs --------------------
|
||||
get totalReports(): number {
|
||||
return new Set(this.ctrTxns.map((t) => t.reportRefNo)).size;
|
||||
return new Set(this.scopedCtrTxns.map((t) => t.reportRefNo)).size;
|
||||
}
|
||||
get totalCtrTxns(): number {
|
||||
return this.ctrTxns.length;
|
||||
return this.scopedCtrTxns.length;
|
||||
}
|
||||
get totalCtrAmount(): number {
|
||||
return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0);
|
||||
return this.scopedCtrTxns.reduce((s, t) => s + t.txnAmount, 0);
|
||||
}
|
||||
get pendingStr(): number {
|
||||
return this.strTxns.filter(
|
||||
return this.scopedStrTxns.filter(
|
||||
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
|
||||
).length;
|
||||
}
|
||||
get filedStr(): number {
|
||||
return this.strTxns.filter((s) => s.status === 'filed').length;
|
||||
return this.scopedStrTxns.filter((s) => s.status === 'filed').length;
|
||||
}
|
||||
|
||||
// ── Live STR case counts (backend `amlStrCases`) — these are the cases the
|
||||
@@ -671,10 +789,48 @@ export class FiuComponent implements OnInit {
|
||||
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
|
||||
}
|
||||
|
||||
// -------------------- Head Office vs Branch scope --------------------
|
||||
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
|
||||
private canonicalBranchOf(branchCode: string): string | undefined {
|
||||
return FIU_BRANCH_CODE_TO_CANONICAL[branchCode];
|
||||
}
|
||||
|
||||
/** Branch name for a row's raw FINnet branch code, for display alongside the code itself. */
|
||||
branchNameFor(branchCode: string): string {
|
||||
const canonical = this.canonicalBranchOf(branchCode);
|
||||
return this.branches.find((b) => b.code === canonical)?.name || '';
|
||||
}
|
||||
|
||||
setBranch(code: string): void {
|
||||
this.branchFilter = code;
|
||||
}
|
||||
|
||||
/** Name of the branch/scope currently being viewed, for display in the header. */
|
||||
get scopeName(): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.currentOfficeName;
|
||||
}
|
||||
return this.branchFilter
|
||||
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
|
||||
: 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
get scopedCtrTxns(): CtrTransaction[] {
|
||||
return this.branchFilter
|
||||
? this.ctrTxns.filter((t) => this.canonicalBranchOf(t.branchCode) === this.branchFilter)
|
||||
: this.ctrTxns;
|
||||
}
|
||||
|
||||
get scopedStrTxns(): StrTransaction[] {
|
||||
return this.branchFilter
|
||||
? this.strTxns.filter((s) => this.canonicalBranchOf(s.branchCode) === this.branchFilter)
|
||||
: this.strTxns;
|
||||
}
|
||||
|
||||
// -------------------- Filtered lists --------------------
|
||||
get filteredCtr(): CtrTransaction[] {
|
||||
const acc = this.ctrFilterAccount.trim();
|
||||
return this.ctrTxns.filter((t) => {
|
||||
return this.scopedCtrTxns.filter((t) => {
|
||||
if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false;
|
||||
if (acc && !t.accountNumber.includes(acc)) return false;
|
||||
if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false;
|
||||
@@ -682,8 +838,19 @@ export class FiuComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */
|
||||
get filteredTc1(): CardTransaction[] {
|
||||
const acc = this.ctrFilterAccount.trim();
|
||||
return this.tc1Txns.filter((c) => {
|
||||
if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false;
|
||||
if (acc && !c.accountNumber.includes(acc)) return false;
|
||||
if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get filteredStr(): StrTransaction[] {
|
||||
return this.strTxns.filter((s) => {
|
||||
return this.scopedStrTxns.filter((s) => {
|
||||
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
|
||||
if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false;
|
||||
return true;
|
||||
@@ -731,9 +898,14 @@ export class FiuComponent implements OnInit {
|
||||
return map[c];
|
||||
}
|
||||
|
||||
// -------------------- Exports --------------------
|
||||
/** TC2 — Branch / cash transaction CSV */
|
||||
exportTc2(): void {
|
||||
// -------------------- FINnet file builders --------------------
|
||||
/**
|
||||
* Each FINnet file is built once, here, and used for both the on-screen
|
||||
* preview and the CSV download. Keeping a single builder per file is what
|
||||
* guarantees the branch sees on screen exactly what is submitted — the two
|
||||
* used to be written out separately, which let them drift.
|
||||
*/
|
||||
private buildTc2(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -752,7 +924,7 @@ export class FiuComponent implements OnInit {
|
||||
'Third Party PAN (Non-Customer)',
|
||||
'Declaration (If Third Party PAN (Non-Customer) is not available)'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const t of this.filteredCtr) {
|
||||
rows.push([
|
||||
t.reportType,
|
||||
@@ -773,12 +945,11 @@ export class FiuComponent implements OnInit {
|
||||
t.declaration
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'TC2.csv');
|
||||
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** TC1 header-only template (UI doesn't track ATM/card txns) */
|
||||
exportTc1(): void {
|
||||
/** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
|
||||
private buildTc1(): FinnetTable {
|
||||
const headers = [
|
||||
'Relationship Flag',
|
||||
'Transaction Status',
|
||||
@@ -799,12 +970,34 @@ export class FiuComponent implements OnInit {
|
||||
'Branch Code of Account',
|
||||
'Report Type'
|
||||
];
|
||||
this.saveCsv([headers], 'TC1.csv');
|
||||
this.toast('TC1 template exported (header only)');
|
||||
const rows: string[][] = [];
|
||||
for (const c of this.filteredTc1) {
|
||||
rows.push([
|
||||
c.relationshipFlag,
|
||||
c.txnStatus,
|
||||
c.txnId.toString(),
|
||||
c.txnDate.replace(/-/g, '/'),
|
||||
c.txnTime,
|
||||
c.depositOrWithdrawal,
|
||||
c.txnAmount.toString(),
|
||||
c.accountNumber,
|
||||
c.accountType,
|
||||
c.cardType,
|
||||
c.cardNumber,
|
||||
c.cardIssuingCountry,
|
||||
c.cardIssuingBank,
|
||||
c.reportRefNo.toString(),
|
||||
c.switchTxnId,
|
||||
c.atmId,
|
||||
c.branchCode,
|
||||
c.reportType
|
||||
]);
|
||||
}
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** KC1 — Individual customers */
|
||||
exportKc1(): void {
|
||||
private buildKc1(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Reference Number',
|
||||
'UCIC',
|
||||
@@ -851,7 +1044,7 @@ export class FiuComponent implements OnInit {
|
||||
'Other Identifier Number',
|
||||
'Primary Entity'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const k of this.kc1) {
|
||||
rows.push([
|
||||
k.reportRefNo.toString(),
|
||||
@@ -900,12 +1093,11 @@ export class FiuComponent implements OnInit {
|
||||
'Yes'
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'KC1.csv');
|
||||
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** KC2 — Entity customers */
|
||||
exportKc2(): void {
|
||||
private buildKc2(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Reference Number',
|
||||
'UCIC',
|
||||
@@ -940,7 +1132,7 @@ export class FiuComponent implements OnInit {
|
||||
'UCIC of Owner of Sole Proprietor Firm',
|
||||
'Name of Owner of Sole Proprietor Firm'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const k of this.kc2) {
|
||||
rows.push([
|
||||
k.reportRefNo.toString(),
|
||||
@@ -977,12 +1169,11 @@ export class FiuComponent implements OnInit {
|
||||
k.ownerName
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'KC2.csv');
|
||||
this.toast(`KC2 exported (${this.kc2.length} entities)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** Account_Detail.csv — per-report account aggregates (12-month) */
|
||||
exportAccountDetail(): void {
|
||||
/** Account_Detail — per-report account aggregates (12-month) */
|
||||
private buildAccountDetail(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -1001,7 +1192,7 @@ export class FiuComponent implements OnInit {
|
||||
'Total Cash Deposit (In last 12 months)',
|
||||
'Total Cash Withdrawal (In last 12 months) Amount'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
// Build one aggregate row per (reportRefNo, accountNumber) pair
|
||||
const seen = new Set<string>();
|
||||
for (const t of this.filteredCtr) {
|
||||
@@ -1039,12 +1230,11 @@ export class FiuComponent implements OnInit {
|
||||
.toString()
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'Account_Detail.csv');
|
||||
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** Account_Person_Relation.csv — account ↔ customer mapping */
|
||||
exportAccountPersonRelation(): void {
|
||||
/** Account_Person_Relation — account ↔ customer mapping */
|
||||
private buildAccountPersonRelation(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -1055,7 +1245,7 @@ export class FiuComponent implements OnInit {
|
||||
'Individual / Non-individual',
|
||||
'Name of Non-customer'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const t of this.filteredCtr) {
|
||||
const key = `${t.reportRefNo}|${t.accountNumber}`;
|
||||
@@ -1074,8 +1264,145 @@ export class FiuComponent implements OnInit {
|
||||
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'Account_Person_Relation.csv');
|
||||
this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// -------------------- FINnet file sequence --------------------
|
||||
|
||||
/**
|
||||
* The six CTR files in the order FIU-IND expects them: the account is
|
||||
* described first, then who is behind it, then the transactions, and finally
|
||||
* the customer cards those transactions belong to.
|
||||
*/
|
||||
private get allFinnetFiles(): FinnetFile[] {
|
||||
return [
|
||||
{
|
||||
key: 'account-detail',
|
||||
name: 'Account_Detail',
|
||||
step: 1,
|
||||
title: 'Account Detail',
|
||||
purpose:
|
||||
'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.',
|
||||
table: this.buildAccountDetail()
|
||||
},
|
||||
{
|
||||
key: 'account-person-relation',
|
||||
name: 'Account_Person_Relation',
|
||||
step: 2,
|
||||
title: 'Account ↔ Person Relation',
|
||||
purpose:
|
||||
'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.',
|
||||
table: this.buildAccountPersonRelation()
|
||||
},
|
||||
{
|
||||
key: 'tc1',
|
||||
name: 'TC1',
|
||||
step: 3,
|
||||
title: 'TC1 — ATM / Card Transactions',
|
||||
purpose:
|
||||
'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.',
|
||||
table: this.buildTc1()
|
||||
},
|
||||
{
|
||||
key: 'tc2',
|
||||
name: 'TC2',
|
||||
step: 4,
|
||||
title: 'TC2 — Branch / Cash Transactions',
|
||||
purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.',
|
||||
table: this.buildTc2()
|
||||
},
|
||||
{
|
||||
key: 'kc1',
|
||||
name: 'KC1',
|
||||
step: 5,
|
||||
title: 'KC1 — Individual Customer Cards',
|
||||
purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.',
|
||||
table: this.buildKc1()
|
||||
},
|
||||
{
|
||||
key: 'kc2',
|
||||
name: 'KC2',
|
||||
step: 6,
|
||||
title: 'KC2 — Entity Customer Cards',
|
||||
purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.',
|
||||
table: this.buildKc2()
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The files walked through in the CTR tab: account, then who is behind it,
|
||||
* then the two transaction files. The customer cards are excluded here and
|
||||
* live on the Customer Cards tab instead, though they still travel with the
|
||||
* full bundle.
|
||||
*/
|
||||
get finnetSequence(): FinnetFile[] {
|
||||
return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2');
|
||||
}
|
||||
|
||||
/** Which file in the sequence is open in the CTR tab. */
|
||||
activeFinnetFile = 'account-detail';
|
||||
|
||||
get openFinnetFile(): FinnetFile {
|
||||
return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0];
|
||||
}
|
||||
|
||||
showFinnetFile(key: string): void {
|
||||
this.activeFinnetFile = key;
|
||||
}
|
||||
|
||||
/** Downloads whichever file is currently previewed, so the button matches the table. */
|
||||
downloadOpenFinnetFile(): void {
|
||||
const f = this.openFinnetFile;
|
||||
this.saveCsv(
|
||||
[
|
||||
f.table.headers,
|
||||
...f.table.rows
|
||||
],
|
||||
`${f.name}.csv`
|
||||
);
|
||||
this.toast(`${f.name} exported (${f.table.rows.length} rows)`);
|
||||
}
|
||||
|
||||
// -------------------- Exports --------------------
|
||||
|
||||
private saveTable(file: FinnetFile): void {
|
||||
this.saveCsv(
|
||||
[
|
||||
file.table.headers,
|
||||
...file.table.rows
|
||||
],
|
||||
`${file.name}.csv`
|
||||
);
|
||||
this.toast(`${file.name} exported (${file.table.rows.length} rows)`);
|
||||
}
|
||||
|
||||
private fileByKey(key: string): FinnetFile {
|
||||
return this.allFinnetFiles.find((f) => f.key === key)!;
|
||||
}
|
||||
|
||||
exportTc1(): void {
|
||||
this.saveTable(this.fileByKey('tc1'));
|
||||
}
|
||||
|
||||
exportTc2(): void {
|
||||
this.saveTable(this.fileByKey('tc2'));
|
||||
}
|
||||
|
||||
exportKc1(): void {
|
||||
this.saveTable(this.fileByKey('kc1'));
|
||||
}
|
||||
|
||||
exportKc2(): void {
|
||||
this.saveTable(this.fileByKey('kc2'));
|
||||
}
|
||||
|
||||
exportAccountDetail(): void {
|
||||
this.saveTable(this.fileByKey('account-detail'));
|
||||
}
|
||||
|
||||
exportAccountPersonRelation(): void {
|
||||
this.saveTable(this.fileByKey('account-person-relation'));
|
||||
}
|
||||
|
||||
/** STR XML stub (FINnet 2.0 STR XML) */
|
||||
@@ -1121,15 +1448,16 @@ export class FiuComponent implements OnInit {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
|
||||
/**
|
||||
* Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the
|
||||
* order FIU-IND expects them — account, then relation, then TC, then KC.
|
||||
*/
|
||||
exportAll(): void {
|
||||
setTimeout(() => this.exportTc1(), 0);
|
||||
setTimeout(() => this.exportTc2(), 200);
|
||||
setTimeout(() => this.exportKc1(), 400);
|
||||
setTimeout(() => this.exportKc2(), 600);
|
||||
setTimeout(() => this.exportAccountDetail(), 800);
|
||||
setTimeout(() => this.exportAccountPersonRelation(), 1000);
|
||||
setTimeout(() => this.exportStrXml(), 1200);
|
||||
// All six travel together — the customer cards are not walked through in the
|
||||
// CTR tab, but a bundle without them would be incomplete.
|
||||
const files = this.allFinnetFiles;
|
||||
files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
|
||||
setTimeout(() => this.exportStrXml(), files.length * 200);
|
||||
}
|
||||
|
||||
private escapeXml(s: string): string {
|
||||
|
||||
@@ -192,8 +192,8 @@
|
||||
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Date</span><input type="text" [(ngModel)]="purchaseForm.dealDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Time (24 hr, HH:mm:ss)</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
|
||||
@@ -221,8 +221,8 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Settlement Dt</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.settlementDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Party Code</span>
|
||||
<select [(ngModel)]="purchaseForm.partyCode">
|
||||
@@ -333,8 +333,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Issue Date</span><input type="text" [(ngModel)]="purchaseForm.issueDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
|
||||
/></label>
|
||||
@@ -354,8 +354,8 @@
|
||||
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Meet Dt</span><input type="text" [(ngModel)]="purchaseForm.meetDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
|
||||
/></label>
|
||||
@@ -394,11 +394,11 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Int Date</span><input type="text" [(ngModel)]="purchaseForm.intDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Last Int.dt</span><input type="text" [(ngModel)]="purchaseForm.lastIntDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Accrued Interest (computed)</span
|
||||
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
|
||||
@@ -554,8 +554,8 @@
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Redemption Date *</span><input type="text" [(ngModel)]="redeemDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Redemption Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="redeemDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Redemption Proceeds (computed)</span
|
||||
><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly
|
||||
@@ -575,11 +575,14 @@
|
||||
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="intCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="intCalcType">
|
||||
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
@for (o of bondTypeOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
@@ -599,11 +602,25 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="intCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
|
||||
</div>
|
||||
@if (intCalcExcludedNpi.length > 0) {
|
||||
<p class="pg-sub" style="padding: 0 16px 12px">
|
||||
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
|
||||
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
|
||||
</p>
|
||||
}
|
||||
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -694,8 +711,8 @@
|
||||
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Date</span><input type="text" [(ngModel)]="sellForm.dealDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.dealDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss"
|
||||
/></label>
|
||||
@@ -708,8 +725,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Settlement Dt</span><input type="text" [(ngModel)]="sellForm.settlementDt" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Settlement Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.settlementDt"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0"
|
||||
/></label>
|
||||
@@ -942,8 +959,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Date *</span><input type="text" [(ngModel)]="goiTransferDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="goiTransferDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button>
|
||||
@@ -958,8 +975,8 @@
|
||||
<div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Valuation Date</span><input type="text" [(ngModel)]="depreciationDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Valuation Date</span><mifosx-date-ddmmyyyy [(ngModel)]="depreciationDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Category</span>
|
||||
<select [(ngModel)]="depreciationCategory">
|
||||
@@ -1043,8 +1060,8 @@
|
||||
><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Date *</span><input type="text" [(ngModel)]="mfTransferDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="mfTransferDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transfer Value (computed)</span
|
||||
><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly
|
||||
@@ -1069,6 +1086,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1082,11 +1100,12 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No transactions yet.</td>
|
||||
<td colspan="7" class="pg-empty">No transactions yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
NON_SLR_HOLDINGS,
|
||||
@@ -30,12 +31,16 @@ import {
|
||||
BOND_RATING_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
AUTHORITY_OPTIONS,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InstrumentType,
|
||||
InterestFreq,
|
||||
INSTRUMENT_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
@@ -173,7 +178,8 @@ interface BondSellForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class BondInvestmentComponent {
|
||||
@@ -224,12 +230,17 @@ export class BondInvestmentComponent {
|
||||
redeemDate = this.asOnDate;
|
||||
redeemRemarks = '';
|
||||
|
||||
// Interest Calculation (legacy: Date / Type / Script / Deal No / ALL + Process)
|
||||
intCalcDate = this.asOnDate;
|
||||
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
|
||||
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
|
||||
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
|
||||
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
intCalcToDate = this.asOnDate;
|
||||
intCalcType: BondType | '' = '';
|
||||
intCalcScript = '';
|
||||
intCalcDealNo = '';
|
||||
intCalcAll = false;
|
||||
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
|
||||
// Write-off
|
||||
writeoffCertNo = '';
|
||||
@@ -647,13 +658,15 @@ export class BondInvestmentComponent {
|
||||
}
|
||||
|
||||
// -------------------- Interest calculation --------------------
|
||||
/** Holdings the Interest Calculation "Process" run would cover, given the filters. */
|
||||
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
|
||||
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
|
||||
* Interest module (it's still collectible on cash basis via Interest Receive there). */
|
||||
get intCalcTargets(): InvestmentHolding[] {
|
||||
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||
if (this.intCalcAll) {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE');
|
||||
return base;
|
||||
}
|
||||
return this.holdings.filter((h) => {
|
||||
if (h.status !== 'ACTIVE') return false;
|
||||
return base.filter((h) => {
|
||||
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
|
||||
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
|
||||
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
|
||||
@@ -661,31 +674,62 @@ export class BondInvestmentComponent {
|
||||
});
|
||||
}
|
||||
|
||||
get intCalcExcludedNpi(): InvestmentHolding[] {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
|
||||
}
|
||||
|
||||
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
|
||||
onIntCalcTypeChange(): void {
|
||||
if (this.intCalcType) {
|
||||
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
|
||||
}
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
|
||||
* Interest Calculation & Post run, same basis as the Investment Interest module. */
|
||||
interestAccrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.intCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
processInterest(): void {
|
||||
const targets = this.intCalcTargets;
|
||||
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No holdings match the selected filters.');
|
||||
this.toast('No holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
||||
txnDate: this.intCalcDate || this.asOnDate,
|
||||
amount: this.interestAccrued(h),
|
||||
particulars: `Interest calculated - ${h.scriptName}`
|
||||
}));
|
||||
const toDate = this.intCalcToDate || this.asOnDate;
|
||||
const fromDate = this.intCalcFromDate || toDate;
|
||||
const glCode = this.intCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.interestAccrued(h);
|
||||
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
this.txns = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.intCalcFromDate = toDate;
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
this.toast(`Interest processed for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.toast(
|
||||
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Sell / Redeem Entry --------------------
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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, ElementRef, ViewChild, forwardRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `<input type="text" placeholder="DD-MM-YYYY">` used throughout the
|
||||
* Investment module. Keeps the DD-MM-YYYY string every date field/helper in investment-data.ts
|
||||
* already expects (parseDDMMYYYY, addPeriod, etc.), but adds a calendar button that opens the
|
||||
* browser's native date picker and converts the picked value back to DD-MM-YYYY — so users can
|
||||
* either type the date or pick it, without changing any consuming component's data model.
|
||||
*
|
||||
* Usage: <mifosx-date-ddmmyyyy [(ngModel)]="someField"></mifosx-date-ddmmyyyy>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-date-ddmmyyyy',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => DateDdmmyyyyPickerComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="inv-date-field">
|
||||
<input
|
||||
type="text"
|
||||
class="inv-date-field__text"
|
||||
[ngModel]="value"
|
||||
(ngModelChange)="onTextChange($event)"
|
||||
(blur)="onTouched()"
|
||||
placeholder="DD-MM-YYYY"
|
||||
[disabled]="disabled"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inv-date-field__btn"
|
||||
tabindex="-1"
|
||||
[disabled]="disabled"
|
||||
(click)="openPicker()"
|
||||
aria-label="Pick a date"
|
||||
title="Pick a date"
|
||||
>
|
||||
📅
|
||||
</button>
|
||||
<input
|
||||
#native
|
||||
type="date"
|
||||
class="inv-date-field__native"
|
||||
tabindex="-1"
|
||||
[ngModel]="isoValue"
|
||||
(ngModelChange)="onPickerChange($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* Angular view encapsulation stops the host page's .inv-form-field styles from reaching
|
||||
* inside this component's own template, so the look of the surrounding form fields
|
||||
* (height, border, radius, font) is deliberately re-declared here instead of inherited. */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-date-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-date-field__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.inv-date-field__btn {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.inv-date-field__btn:hover:not(:disabled) {
|
||||
background: #f2f4f7;
|
||||
}
|
||||
.inv-date-field__btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.inv-date-field__native {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DateDdmmyyyyPickerComponent implements ControlValueAccessor {
|
||||
@ViewChild('native') private nativeInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
|
||||
private onChangeFn: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
/** DD-MM-YYYY -> yyyy-MM-dd, the format <input type="date"> requires. */
|
||||
get isoValue(): string {
|
||||
const parts = (this.value || '').split('-');
|
||||
if (parts.length !== 3) {
|
||||
return '';
|
||||
}
|
||||
const [
|
||||
dd,
|
||||
mm,
|
||||
yyyy
|
||||
] = parts;
|
||||
if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy)) {
|
||||
return '';
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
writeValue(v: string): void {
|
||||
this.value = v ?? '';
|
||||
}
|
||||
|
||||
registerOnChange(fn: (v: string) => void): void {
|
||||
this.onChangeFn = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
onTextChange(v: string): void {
|
||||
this.value = v;
|
||||
this.onChangeFn(v);
|
||||
}
|
||||
|
||||
/** yyyy-MM-dd (from the native picker) -> DD-MM-YYYY, the format the rest of the app expects. */
|
||||
onPickerChange(iso: string): void {
|
||||
const parts = (iso || '').split('-');
|
||||
if (parts.length !== 3) {
|
||||
return;
|
||||
}
|
||||
const [
|
||||
yyyy,
|
||||
mm,
|
||||
dd
|
||||
] = parts;
|
||||
const ddmmyyyy = `${dd}-${mm}-${yyyy}`;
|
||||
this.value = ddmmyyyy;
|
||||
this.onChangeFn(ddmmyyyy);
|
||||
this.onTouched();
|
||||
}
|
||||
|
||||
openPicker(): void {
|
||||
const el = this.nativeInput?.nativeElement;
|
||||
if (!el || this.disabled) {
|
||||
return;
|
||||
}
|
||||
const showPicker = (el as HTMLInputElement & { showPicker?: () => void }).showPicker;
|
||||
if (typeof showPicker === 'function') {
|
||||
try {
|
||||
showPicker.call(el);
|
||||
return;
|
||||
} catch {
|
||||
// Some browsers throw if not called from a "user gesture" context they recognise —
|
||||
// fall back to a plain click(), which still opens the native picker.
|
||||
}
|
||||
}
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,8 @@
|
||||
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Sub Type</span>
|
||||
<select [(ngModel)]="purchaseForm.subType">
|
||||
@@ -198,12 +198,12 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transaction Date</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Date *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
|
||||
/></label>
|
||||
@@ -217,8 +217,8 @@
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Date</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
|
||||
/></label>
|
||||
@@ -235,8 +235,8 @@
|
||||
} @else {
|
||||
<label class="inv-form-field"
|
||||
><span>Maturity Date (manual) *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
|
||||
@@ -327,11 +327,25 @@
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="interestCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
@for (h of interestEligibleHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="interestGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (interestHolding; as h) {
|
||||
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
|
||||
<label class="inv-form-field"
|
||||
@@ -341,7 +355,7 @@
|
||||
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Accrued Interest (as on {{ asOnDate }})</span
|
||||
><span>Interest for Period</span
|
||||
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
@@ -376,8 +390,8 @@
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
@@ -454,6 +468,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -467,6 +482,7 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
FD_TYPE_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
LIEN_REASON_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InterestFreq,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
@@ -83,7 +88,8 @@ interface FdPurchaseForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class FdInvestmentComponent {
|
||||
@@ -98,6 +104,7 @@ export class FdInvestmentComponent {
|
||||
readonly fdTypeOptions = FD_TYPE_OPTIONS;
|
||||
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
||||
readonly lienReasonOptions = LIEN_REASON_MASTER;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
readonly branchOptions = [
|
||||
'HEAD OFFICE',
|
||||
'BRANCH 1',
|
||||
@@ -114,6 +121,9 @@ export class FdInvestmentComponent {
|
||||
searchCertNo = '';
|
||||
|
||||
interestCertNo = '';
|
||||
interestFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
interestToDate = this.asOnDate;
|
||||
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
sellCertNo = '';
|
||||
sellDate = this.asOnDate;
|
||||
sellRemark = '';
|
||||
@@ -313,12 +323,24 @@ export class FdInvestmentComponent {
|
||||
}
|
||||
|
||||
// -------------------- Interest --------------------
|
||||
/** Certificates eligible for accrual-basis posting — same rule as the Investment Interest
|
||||
* module: a Non-Performing holding never appears here, only through Interest Receive. */
|
||||
get interestEligibleHoldings(): InvestmentHolding[] {
|
||||
return this.activeHoldings.filter((h) => canAccrueInterest(h));
|
||||
}
|
||||
|
||||
get interestHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.interestCertNo);
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based
|
||||
* Interest Calculation & Post run, same basis as the Investment Interest module. */
|
||||
interestAccrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
|
||||
}
|
||||
|
||||
postInterest(): void {
|
||||
@@ -328,19 +350,34 @@ export class FdInvestmentComponent {
|
||||
return;
|
||||
}
|
||||
const amt = this.interestAccrued(h);
|
||||
if (amt <= 0) {
|
||||
this.toast('No interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.interestToDate || this.asOnDate;
|
||||
const fromDate = this.interestFromDate || toDate;
|
||||
const glCode = this.interestGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
this.interestFromDate = toDate;
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Interest posted - ${h.party} (${h.interestFreq})`
|
||||
particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
|
||||
glCode,
|
||||
glName
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`);
|
||||
this.toast(
|
||||
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
|
||||
);
|
||||
this.interestCertNo = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -85,10 +85,16 @@
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
|
||||
<p class="pg-sub">
|
||||
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
|
||||
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
|
||||
the room that framework leaves.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instrument Type</th>
|
||||
<th>Target Basis</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num">Actual %</th>
|
||||
<th class="num">Actual Value</th>
|
||||
@@ -100,6 +106,11 @@
|
||||
@for (a of allocationVsTarget; track a.type) {
|
||||
<tr>
|
||||
<td>{{ a.label }}</td>
|
||||
<td>
|
||||
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
|
||||
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ a.targetPct }}%</td>
|
||||
<td class="num">{{ a.actualPct }}%</td>
|
||||
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ALL_HOLDINGS,
|
||||
ALLOCATION_TARGET_IS_RBI_LINKED,
|
||||
INSTRUMENT_LABELS,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
@@ -39,6 +40,9 @@ interface AllocationRow {
|
||||
actualValue: number;
|
||||
variancePct: number;
|
||||
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
|
||||
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
|
||||
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
|
||||
isRbiLinked: boolean;
|
||||
}
|
||||
|
||||
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
|
||||
@@ -114,7 +118,8 @@ export class InvestmentDashboardComponent {
|
||||
actualPct: +actualPct.toFixed(1),
|
||||
actualValue: value,
|
||||
variancePct,
|
||||
statusLabel
|
||||
statusLabel,
|
||||
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -195,6 +200,7 @@ export class InvestmentDashboardComponent {
|
||||
subtitle: `As on ${this.asOnDate}`,
|
||||
headers: [
|
||||
'Instrument Type',
|
||||
'Target Basis',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Actual Value',
|
||||
@@ -203,6 +209,7 @@ export class InvestmentDashboardComponent {
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
20,
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
@@ -211,6 +218,7 @@ export class InvestmentDashboardComponent {
|
||||
],
|
||||
rows: this.allocationVsTarget.map((a) => [
|
||||
a.label,
|
||||
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
||||
a.targetPct,
|
||||
a.actualPct,
|
||||
a.actualValue,
|
||||
|
||||
@@ -53,16 +53,45 @@ export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string>
|
||||
LOSS: 'Loss (NPI)'
|
||||
};
|
||||
|
||||
/** Board-approved portfolio-policy target — % of the total investment corpus each instrument
|
||||
* type should hold. Compared against actual holdings on the Dashboard so a treasury desk can
|
||||
* see at a glance where the book has drifted from policy. */
|
||||
/**
|
||||
* Portfolio-policy target — % of the total investment corpus each instrument type should hold.
|
||||
* Compared against actual holdings on the Dashboard so a treasury desk can see at a glance where
|
||||
* the book has drifted from policy. Four of these six trace back to real RBI rules for Urban
|
||||
* Co-operative Banks (RBI Master Direction/Circular — Investment Portfolio of Primary (Urban)
|
||||
* Co-operative Banks):
|
||||
* • SLR: UCBs must hold 18% of NDTL in approved securities — predominantly Central/State Govt
|
||||
* securities (and SLR-eligible State Development Loan bonds). A well-run treasury holds a
|
||||
* safety buffer above the bare floor, so Govt Security dominates the book far beyond the 18%
|
||||
* minimum — it isn't itself an upper target, just the reason this bucket is the largest.
|
||||
* • Non-SLR ceiling: a UCB's Non-SLR investments must not exceed 10% of its total deposits as
|
||||
* on 31 March of the previous year — a hard regulatory cap, not a board choice.
|
||||
* • Mutual Funds: RBI permits UCBs to invest only in debt-oriented schemes of SEBI-registered
|
||||
* mutual funds, in strictly limited amounts — equity/balanced schemes are not permitted.
|
||||
* • Shares: RBI does not permit UCBs to invest in equity shares of companies as a general
|
||||
* investment class; any "Share" holding is essentially the (small, largely static) shares a
|
||||
* UCB holds in affiliated co-operative institutions, not a discretionary trading book.
|
||||
* Fixed Deposit (treasury placements with other banks) and Bond (a mix of SLR-eligible SDL and
|
||||
* Non-SLR PSU/FI bonds) have no RBI-mandated percentage of their own — their split here is a
|
||||
* board-approved policy choice within the room RBI's SLR/Non-SLR framework leaves.
|
||||
*/
|
||||
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
|
||||
GOVT_SECURITY: 55,
|
||||
BOND: 18,
|
||||
FD: 15,
|
||||
GOVT_SECURITY: 35,
|
||||
NON_SLR: 10,
|
||||
BOND: 20,
|
||||
MUTUAL_FUND: 12,
|
||||
SHARE: 8
|
||||
NON_SLR: 7,
|
||||
MUTUAL_FUND: 3,
|
||||
SHARE: 2
|
||||
};
|
||||
|
||||
/** True for the instrument types whose target traces to an actual RBI rule (SLR floor / Non-SLR
|
||||
* ceiling) rather than a board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment. */
|
||||
export const ALLOCATION_TARGET_IS_RBI_LINKED: Record<InstrumentType, boolean> = {
|
||||
GOVT_SECURITY: true,
|
||||
BOND: false,
|
||||
FD: false,
|
||||
NON_SLR: true,
|
||||
MUTUAL_FUND: true,
|
||||
SHARE: true
|
||||
};
|
||||
|
||||
export interface InvestmentHolding {
|
||||
@@ -90,6 +119,10 @@ export interface InvestmentHolding {
|
||||
interestPosted?: number;
|
||||
/** Cumulative interest actually received in cash to date. */
|
||||
interestReceived?: number;
|
||||
/** DD-MM-YYYY through which interest has already been calculated & posted. accruedInterestBetween()
|
||||
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
|
||||
* or repeated period always yields 0 instead of double-posting the same interest. */
|
||||
interestPostedTillDate?: string;
|
||||
}
|
||||
|
||||
export interface InvestmentTxn {
|
||||
@@ -100,6 +133,10 @@ export interface InvestmentTxn {
|
||||
txnDate: string;
|
||||
amount: number;
|
||||
particulars: string;
|
||||
/** GL account this entry was posted/received against — the Interest Income GL for a Post,
|
||||
* the Cash/Bank GL for a Receive. Absent on older/seeded entries that predate GL selection. */
|
||||
glCode?: string;
|
||||
glName?: string;
|
||||
}
|
||||
|
||||
// -------------------- Date / interest helpers --------------------
|
||||
@@ -175,15 +212,6 @@ export function canAccrueInterest(h: InvestmentHolding): boolean {
|
||||
return !isNpi(h);
|
||||
}
|
||||
|
||||
/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting
|
||||
* an accrual on a non-performing investment would overstate income. */
|
||||
export function interestToPost(h: InvestmentHolding, asOnStr: string): number {
|
||||
if (!canAccrueInterest(h)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0));
|
||||
}
|
||||
|
||||
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
|
||||
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
|
||||
* posted as a receivable on one. */
|
||||
@@ -203,6 +231,23 @@ export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number
|
||||
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
|
||||
}
|
||||
|
||||
/** Interest accrued strictly within [fromStr, toStr], clipped to the holding's own
|
||||
* purchase→maturity window — the basis for a period-based Interest Calculation & Post run
|
||||
* (as opposed to accruedInterest(), which is always measured from purchase date). Returns 0
|
||||
* for a Non-Performing holding, same accrual-basis rule as accruedInterest(). */
|
||||
export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, toStr: string): number {
|
||||
if (!canAccrueInterest(h)) {
|
||||
return 0;
|
||||
}
|
||||
const purchase = parseDDMMYYYY(h.purchaseDate).getTime();
|
||||
const maturity = parseDDMMYYYY(h.maturityDate).getTime();
|
||||
const postedTill = h.interestPostedTillDate ? parseDDMMYYYY(h.interestPostedTillDate).getTime() : purchase;
|
||||
const from = Math.max(parseDDMMYYYY(fromStr).getTime(), purchase, postedTill);
|
||||
const to = Math.min(parseDDMMYYYY(toStr).getTime(), maturity);
|
||||
const days = Math.max(0, (to - from) / (24 * 3600 * 1000));
|
||||
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
|
||||
}
|
||||
|
||||
export function formatDDMMYYYY(d: Date): string {
|
||||
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
}
|
||||
@@ -388,6 +433,68 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: '15628',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-CUM',
|
||||
scriptName: 'Cumulative Fixed Deposit',
|
||||
party: 'Zilla Sahakari Bank Ltd',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1800000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '20-08-2025',
|
||||
maturityDate: '20-08-2026',
|
||||
interestRate: 6.8,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: '15629',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-MIP',
|
||||
scriptName: 'Monthly Interest Payout FD',
|
||||
party: 'Vidarbha Cooperative Bank',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 2200000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '25-09-2025',
|
||||
maturityDate: '25-09-2026',
|
||||
interestRate: 7.15,
|
||||
interestFreq: 'MONTHLY',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE',
|
||||
interestPosted: 143220,
|
||||
interestReceived: 130800
|
||||
},
|
||||
{
|
||||
certNo: '15630',
|
||||
instrumentType: 'FD',
|
||||
scriptCode: 'FD-CUM',
|
||||
scriptName: 'Cumulative Fixed Deposit',
|
||||
party: 'Konkan Cooperative Bank Ltd',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 900000,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: '28-10-2024',
|
||||
maturityDate: '28-10-2026',
|
||||
interestRate: 6.75,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '320020',
|
||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -515,6 +622,46 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-002',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'GSEC-2007',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
scriptCode: 'TBILL-91D',
|
||||
scriptName: '91 Days Treasury Bill',
|
||||
party: 'RBI - SGL',
|
||||
subType: 'SLR',
|
||||
faceValue: 2000000,
|
||||
purchaseRate: 98.2,
|
||||
units: 20000,
|
||||
purchaseDate: '15-05-2026',
|
||||
maturityDate: '13-08-2026',
|
||||
interestRate: 6.2,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310020',
|
||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-003',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'GSEC-2008',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
scriptCode: 'GJ-SDL-2026',
|
||||
scriptName: '7.85% Gujarat SDL 2026',
|
||||
party: 'RBI - SGL',
|
||||
subType: 'SLR',
|
||||
faceValue: 1800000,
|
||||
purchaseRate: 100.2,
|
||||
units: 18000,
|
||||
purchaseDate: '18-09-2025',
|
||||
maturityDate: '18-09-2026',
|
||||
interestRate: 7.85,
|
||||
interestFreq: 'HALF_YEARLY',
|
||||
glCode: '310020',
|
||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-001',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -620,6 +767,46 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
|
||||
status: 'ACTIVE',
|
||||
interestPosted: 89400,
|
||||
interestReceived: 60000
|
||||
},
|
||||
{
|
||||
certNo: 'NSLR-3006',
|
||||
instrumentType: 'NON_SLR',
|
||||
scriptCode: 'IRFC-7.55-2026',
|
||||
scriptName: 'IRFC Bond 7.55% 2026',
|
||||
party: 'Indian Railway Finance Corp',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1600000,
|
||||
purchaseRate: 100,
|
||||
units: 16000,
|
||||
purchaseDate: '25-08-2025',
|
||||
maturityDate: '25-08-2026',
|
||||
interestRate: 7.55,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310030',
|
||||
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-004',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'NSLR-3007',
|
||||
instrumentType: 'NON_SLR',
|
||||
scriptCode: 'NHB-7.3-2026',
|
||||
scriptName: 'NHB Bond 7.30% 2026',
|
||||
party: 'National Housing Bank',
|
||||
subType: 'NON SLR',
|
||||
faceValue: 1000000,
|
||||
purchaseRate: 100,
|
||||
units: 10000,
|
||||
purchaseDate: '10-10-2025',
|
||||
maturityDate: '10-10-2026',
|
||||
interestRate: 7.3,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310030',
|
||||
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-003',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -726,6 +913,46 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-005',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'BOND-4006',
|
||||
instrumentType: 'BOND',
|
||||
scriptCode: 'CHOLA-8.35-2026',
|
||||
scriptName: 'Cholamandalam Finance NCD 8.35% 2026',
|
||||
party: 'Cholamandalam Investment & Finance',
|
||||
subType: 'CORPORATE BOND',
|
||||
faceValue: 1400000,
|
||||
purchaseRate: 100,
|
||||
units: 14000,
|
||||
purchaseDate: '15-09-2024',
|
||||
maturityDate: '15-09-2026',
|
||||
interestRate: 8.35,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310040',
|
||||
glName: 'INVESTMENT IN CORPORATE BONDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-006',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'BOND-4007',
|
||||
instrumentType: 'BOND',
|
||||
scriptCode: 'PEL-8.4-2026',
|
||||
scriptName: 'Piramal Enterprises NCD 8.40% 2026',
|
||||
party: 'Piramal Enterprises Ltd',
|
||||
subType: 'CORPORATE BOND',
|
||||
faceValue: 900000,
|
||||
purchaseRate: 100,
|
||||
units: 9000,
|
||||
purchaseDate: '28-10-2025',
|
||||
maturityDate: '28-10-2026',
|
||||
interestRate: 8.4,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310040',
|
||||
glName: 'INVESTMENT IN CORPORATE BONDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-005',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -829,6 +1056,46 @@ export const MUTUAL_FUND_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'MF-5006',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
scriptCode: 'SBI-BPSU',
|
||||
scriptName: 'SBI Banking & PSU Fund',
|
||||
party: 'SBI Mutual Fund',
|
||||
subType: 'DEBT - BANKING & PSU',
|
||||
faceValue: 700000,
|
||||
purchaseRate: 18.6,
|
||||
units: 37634.4,
|
||||
purchaseDate: '10-09-2025',
|
||||
maturityDate: '10-09-2026',
|
||||
interestRate: 7.1,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310050',
|
||||
glName: 'INVESTMENT IN MUTUAL FUNDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'MF-5007',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
scriptCode: 'ICICI-CB',
|
||||
scriptName: 'ICICI Pru Corporate Bond Fund',
|
||||
party: 'ICICI Prudential Mutual Fund',
|
||||
subType: 'DEBT - CORPORATE BOND',
|
||||
faceValue: 1100000,
|
||||
purchaseRate: 24.8,
|
||||
units: 44354.84,
|
||||
purchaseDate: '14-07-2026',
|
||||
maturityDate: '14-07-2027',
|
||||
interestRate: 7.05,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
glCode: '310050',
|
||||
glName: 'INVESTMENT IN MUTUAL FUNDS',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -932,6 +1199,46 @@ export const SHARE_HOLDINGS: InvestmentHolding[] = [
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'SH-6006',
|
||||
instrumentType: 'SHARE',
|
||||
scriptCode: 'ICICIBANK',
|
||||
scriptName: 'ICICI Bank Ltd',
|
||||
party: 'NSE/BSE',
|
||||
subType: 'EQUITY',
|
||||
faceValue: 420000,
|
||||
purchaseRate: 1050,
|
||||
units: 400,
|
||||
purchaseDate: '05-03-2026',
|
||||
maturityDate: '05-03-2036',
|
||||
interestRate: 2.0,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310060',
|
||||
glName: 'INVESTMENT IN SHARES',
|
||||
crToAc: '310070',
|
||||
brokerCode: 'BRK-007',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
certNo: 'SH-6007',
|
||||
instrumentType: 'SHARE',
|
||||
scriptCode: 'PDCC',
|
||||
scriptName: 'Pune District Central Coop Bank Shares',
|
||||
party: 'Pune District Central Cooperative Bank',
|
||||
subType: 'CO-OP SHARE',
|
||||
faceValue: 60000,
|
||||
purchaseRate: 50,
|
||||
units: 1200,
|
||||
purchaseDate: '18-07-2025',
|
||||
maturityDate: '18-07-2035',
|
||||
interestRate: 5.0,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310060',
|
||||
glName: 'INVESTMENT IN SHARES',
|
||||
crToAc: '310070',
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1060,6 +1367,43 @@ export const ALL_TXNS: InvestmentTxn[] = [
|
||||
|
||||
// -------------------- Masters --------------------
|
||||
|
||||
export interface InvestmentGlOption {
|
||||
glCode: string;
|
||||
glName: string;
|
||||
}
|
||||
|
||||
/** Interest Income (Cr) GL per instrument type — the GL an Interest Calculation & Post run
|
||||
* credits. Selectable on the FD/Govt./Bond Interest Calculation screens instead of being
|
||||
* hard-wired, so a period's posting can be routed to a different income head when needed. */
|
||||
export const INTEREST_INCOME_GL_MASTER: InvestmentGlOption[] = [
|
||||
{ glCode: '410010', glName: 'INTEREST ON GOVT. SECURITIES' },
|
||||
{ glCode: '410020', glName: 'INTEREST ON NON SLR SECURITIES' },
|
||||
{ glCode: '410030', glName: 'INTEREST ON CORPORATE BONDS' },
|
||||
{ glCode: '410040', glName: 'INTEREST ON FIXED DEPOSITS' },
|
||||
{ glCode: '410050', glName: 'INCOME FROM MUTUAL FUNDS' },
|
||||
{ glCode: '410060', glName: 'DIVIDEND ON SHARES' }
|
||||
];
|
||||
|
||||
/** Default Interest Income GL suggested per instrument type — pre-fills the selector above,
|
||||
* which the user can still override before posting. */
|
||||
export const INTEREST_INCOME_GL_BY_TYPE: Record<InstrumentType, string> = {
|
||||
GOVT_SECURITY: '410010',
|
||||
NON_SLR: '410020',
|
||||
BOND: '410030',
|
||||
FD: '410040',
|
||||
MUTUAL_FUND: '410050',
|
||||
SHARE: '410060'
|
||||
};
|
||||
|
||||
/** Cash/Bank (Dr) GL credited when an Interest Receive entry is booked — the account the cash
|
||||
* actually lands in. Selectable on the Interest Receive screen. */
|
||||
export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
|
||||
{ glCode: '100010', glName: 'CASH IN HAND' },
|
||||
{ glCode: '110010', glName: 'RBI CURRENT ACCOUNT' },
|
||||
{ glCode: '110020', glName: 'SBI CURRENT ACCOUNT' },
|
||||
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
|
||||
];
|
||||
|
||||
export interface ScriptMaster {
|
||||
scriptCode: string;
|
||||
scriptName: string;
|
||||
|
||||
@@ -184,8 +184,11 @@
|
||||
<div class="pos-section-label">Investment - FD Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No</span>
|
||||
<select [(ngModel)]="fdCalcCertNo">
|
||||
@@ -198,6 +201,14 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="fdCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
|
||||
@@ -257,11 +268,14 @@
|
||||
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="bondCalcType">
|
||||
<select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
<option value="GOVT_SECURITY">GOVT SECURITY</option>
|
||||
<option value="NON_SLR">NON SLR</option>
|
||||
@@ -281,6 +295,14 @@
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Post To (Interest Income GL)</span>
|
||||
<select [(ngModel)]="bondCalcGlCode">
|
||||
@for (g of interestIncomeGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
|
||||
@@ -342,6 +364,16 @@
|
||||
@if (activeTab === 'receive') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Receive Into (Cash/Bank GL)</span>
|
||||
<select [(ngModel)]="receiveGlCode">
|
||||
@for (g of cashBankGlOptions; track g.glCode) {
|
||||
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -396,6 +428,7 @@
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Credited</th>
|
||||
<th class="num col-in">Amount Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -408,17 +441,18 @@
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No interest received yet.</td>
|
||||
<td colspan="6" class="pg-empty">No interest received yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total Received</td>
|
||||
<td colspan="5">Total Received</td>
|
||||
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -438,6 +472,7 @@
|
||||
<th>Cert No</th>
|
||||
<th>Instrument</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -451,17 +486,18 @@
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ instrumentLabel(t.instrumentType) }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td>
|
||||
<td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total</td>
|
||||
<td colspan="6">Total</td>
|
||||
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -480,6 +516,7 @@
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -492,17 +529,18 @@
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td>
|
||||
<td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total</td>
|
||||
<td colspan="5">Total</td>
|
||||
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
BOND_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
SCRIPT_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
CASH_BANK_GL_MASTER,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InstrumentType,
|
||||
@@ -23,12 +27,13 @@ import {
|
||||
INSTRUMENT_LABELS,
|
||||
PROVISION_STATUS_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
addPeriod,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
todayDDMMYYYY,
|
||||
isNpi,
|
||||
canAccrueInterest,
|
||||
interestToPost,
|
||||
interestReceivableBalance,
|
||||
suspenseInterest,
|
||||
provisionRequiredInv
|
||||
@@ -67,7 +72,8 @@ type InterestTab =
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentInterestComponent {
|
||||
@@ -83,6 +89,8 @@ export class InvestmentInterestComponent {
|
||||
txns: InvestmentTxn[] = [...ALL_TXNS];
|
||||
|
||||
readonly scriptOptions = SCRIPT_MASTER;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
readonly cashBankGlOptions = CASH_BANK_GL_MASTER;
|
||||
|
||||
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
|
||||
receiveDrafts: Record<string, number | null> = {};
|
||||
@@ -92,17 +100,24 @@ export class InvestmentInterestComponent {
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// 01 FD Interest Calculation
|
||||
fdCalcDate = this.asOnDate;
|
||||
// 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
fdCalcToDate = this.asOnDate;
|
||||
fdCalcCertNo = '';
|
||||
fdCalcAll = false;
|
||||
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
|
||||
// 02 Govt./Bond Int. Calculation
|
||||
bondCalcDate = this.asOnDate;
|
||||
// 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
bondCalcToDate = this.asOnDate;
|
||||
bondCalcType: InstrumentType | '' = '';
|
||||
bondCalcScript = '';
|
||||
bondCalcDealNo = '';
|
||||
bondCalcAll = false;
|
||||
bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
|
||||
|
||||
// Interest Receive — Cash/Bank GL credited for the day's collection voucher
|
||||
receiveGlCode = CASH_BANK_GL_MASTER[0].glCode;
|
||||
|
||||
// Register filters
|
||||
registerFromDate = '';
|
||||
@@ -184,38 +199,56 @@ export class InvestmentInterestComponent {
|
||||
}
|
||||
|
||||
fdInterest(h: InvestmentHolding): number {
|
||||
return interestToPost(h, this.fdCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get fdCalcTotal(): number {
|
||||
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
|
||||
}
|
||||
|
||||
glName(glCode: string): string {
|
||||
return (
|
||||
this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ??
|
||||
this.cashBankGlOptions.find((g) => g.glCode === glCode)?.glName ??
|
||||
glCode
|
||||
);
|
||||
}
|
||||
|
||||
processFdInterest(): void {
|
||||
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No FD holdings have interest due to be posted.');
|
||||
this.toast('No FD holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.fdCalcToDate || this.asOnDate;
|
||||
const fromDate = this.fdCalcFromDate || toDate;
|
||||
const glCode = this.fdCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.fdInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: this.fdCalcDate || this.asOnDate,
|
||||
voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `FD interest posted (accrual) - ${h.party}`
|
||||
particulars: `FD interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
this.fdCalcFromDate = toDate;
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
this.txns = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.toast(
|
||||
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- 02 Govt./Bond Int. Calculation --------------------
|
||||
@@ -239,30 +272,44 @@ export class InvestmentInterestComponent {
|
||||
}
|
||||
|
||||
bondInterest(h: InvestmentHolding): number {
|
||||
return interestToPost(h, this.bondCalcDate || this.asOnDate);
|
||||
return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get bondCalcTotal(): number {
|
||||
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
|
||||
}
|
||||
|
||||
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
|
||||
onBondCalcTypeChange(): void {
|
||||
if (this.bondCalcType) {
|
||||
this.bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.bondCalcType];
|
||||
}
|
||||
}
|
||||
|
||||
processBondInterest(): void {
|
||||
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No Govt/Bond holdings have interest due to be posted.');
|
||||
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.bondCalcToDate || this.asOnDate;
|
||||
const fromDate = this.bondCalcFromDate || toDate;
|
||||
const glCode = this.bondCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.bondInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
h.interestPostedTillDate = toDate;
|
||||
return {
|
||||
certNo: h.certNo,
|
||||
instrumentType: h.instrumentType,
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: this.bondCalcDate || this.asOnDate,
|
||||
voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}`
|
||||
particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
|
||||
glCode,
|
||||
glName
|
||||
};
|
||||
});
|
||||
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||
@@ -270,7 +317,10 @@ export class InvestmentInterestComponent {
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`);
|
||||
this.bondCalcFromDate = toDate;
|
||||
this.toast(
|
||||
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Interest Receivable (all interest-bearing types) --------------------
|
||||
@@ -331,6 +381,8 @@ export class InvestmentInterestComponent {
|
||||
h.interestReceived = this.received(h) + amt;
|
||||
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
|
||||
}
|
||||
const glCode = this.receiveGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
@@ -339,11 +391,13 @@ export class InvestmentInterestComponent {
|
||||
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: amt,
|
||||
particulars
|
||||
particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
|
||||
glCode,
|
||||
glName
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`);
|
||||
this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
|
||||
delete this.receiveDrafts[h.certNo];
|
||||
}
|
||||
|
||||
@@ -400,7 +454,9 @@ export class InvestmentInterestComponent {
|
||||
t.certNo,
|
||||
t.instrumentType,
|
||||
t.particulars,
|
||||
t.amount
|
||||
t.amount,
|
||||
t.glCode ?? '',
|
||||
t.glName ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
@@ -412,7 +468,9 @@ export class InvestmentInterestComponent {
|
||||
'Cert No',
|
||||
'Type',
|
||||
'Particulars',
|
||||
'Amount'
|
||||
'Amount',
|
||||
'GL Code',
|
||||
'GL Name'
|
||||
];
|
||||
const txnWidths = [
|
||||
14,
|
||||
@@ -420,7 +478,9 @@ export class InvestmentInterestComponent {
|
||||
14,
|
||||
14,
|
||||
42,
|
||||
16
|
||||
16,
|
||||
12,
|
||||
28
|
||||
];
|
||||
const one = (
|
||||
sheetName: string,
|
||||
@@ -501,72 +561,78 @@ export class InvestmentInterestComponent {
|
||||
return sheets;
|
||||
}
|
||||
case 'fdCalc':
|
||||
return one(
|
||||
'FD Interest Calc',
|
||||
'FD Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.fdCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.fdInterest(h)
|
||||
])
|
||||
);
|
||||
return [
|
||||
{
|
||||
sheetName: 'FD Interest Calc',
|
||||
title: 'FD Interest Calculation & Post',
|
||||
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Party',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.fdCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.party,
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.fdInterest(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
case 'bondCalc':
|
||||
return one(
|
||||
'Bond Interest Calc',
|
||||
'Govt./Bond Interest Calculation & Post',
|
||||
[
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
[
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
this.bondCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.bondInterest(h)
|
||||
])
|
||||
);
|
||||
return [
|
||||
{
|
||||
sheetName: 'Bond Interest Calc',
|
||||
title: 'Govt./Bond Interest Calculation & Post',
|
||||
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script',
|
||||
'Type',
|
||||
'Face Value',
|
||||
'Rate',
|
||||
'Frequency',
|
||||
'Interest'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.bondCalcTargets.map((h) => [
|
||||
h.certNo,
|
||||
h.scriptName,
|
||||
this.instrumentLabel(h.instrumentType),
|
||||
h.faceValue,
|
||||
h.interestRate,
|
||||
h.interestFreq,
|
||||
this.bondInterest(h)
|
||||
])
|
||||
}
|
||||
];
|
||||
case 'receive': {
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Receive Targets',
|
||||
title: 'Interest Receive — Outstanding for Collection',
|
||||
subtitle,
|
||||
subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
|
||||
headers: [
|
||||
'Cert No',
|
||||
'Script / Party',
|
||||
|
||||
@@ -267,8 +267,8 @@
|
||||
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
|
||||
@@ -349,8 +349,8 @@
|
||||
<div class="pos-section-label">Add Daily Script Rate</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Script Code *</span>
|
||||
<select [(ngModel)]="newDailyRate.scriptCode">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
SCRIPT_MASTER,
|
||||
BROKER_MASTER,
|
||||
@@ -60,7 +61,8 @@ type MasterTab =
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentMastersComponent {
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Purchase Date *</span
|
||||
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY"
|
||||
/></label>
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
|
||||
/></label>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
@@ -41,7 +42,8 @@ interface SharePurchaseForm {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class ShareInvestmentComponent {
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
Account Status
|
||||
<span class="km-cnt km-cnt--red">34</span>
|
||||
</button>
|
||||
<button class="km-itab" [class.active]="activeSection === 'exceptions'" (click)="showSection('exceptions')">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exceptions
|
||||
<span class="km-cnt km-cnt--amb">{{ exceptionRows.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="km-main">
|
||||
@@ -722,6 +727,284 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC EXCEPTIONS -->
|
||||
@if (activeSection === 'exceptions') {
|
||||
<div class="km-page">
|
||||
<div class="km-page-head">
|
||||
<span class="km-page-title">KYC Exceptions Dashboard</span>
|
||||
<div class="km-actions">
|
||||
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
|
||||
<fa-icon icon="download"></fa-icon> Export Exceptions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="km-stat-row">
|
||||
<div class="km-stat">
|
||||
<div class="km-stat-n" style="color: var(--km-amber)">{{ exceptionRows.length }}</div>
|
||||
<div class="km-stat-l">Pending Exceptions</div>
|
||||
<div class="km-stat-t" style="color: var(--km-amber)">Immediate attention</div>
|
||||
</div>
|
||||
<div class="km-stat">
|
||||
<div class="km-stat-n" style="color: var(--km-red)">
|
||||
{{ notYetContactedCount }}
|
||||
</div>
|
||||
<div class="km-stat-l">Not Yet Contacted</div>
|
||||
<div class="km-stat-t" style="color: var(--km-red)">Action required</div>
|
||||
</div>
|
||||
<div class="km-stat">
|
||||
<div class="km-stat-n" style="color: var(--km-orange)">
|
||||
{{ panMismatchesCount }}
|
||||
</div>
|
||||
<div class="km-stat-l">PAN Mismatches</div>
|
||||
<div class="km-stat-t" style="color: var(--km-orange)">Requires physical review</div>
|
||||
</div>
|
||||
<div class="km-stat">
|
||||
<div class="km-stat-n" style="color: var(--km-blue)">
|
||||
{{ aadhaarFailuresCount }}
|
||||
</div>
|
||||
<div class="km-stat-l">Aadhaar Failures</div>
|
||||
<div class="km-stat-t" style="color: var(--km-blue)">Re-KYC OTP needed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="km-card km-mt">
|
||||
<!-- Tab Toggle Header -->
|
||||
<div
|
||||
class="km-ch"
|
||||
style="
|
||||
border-bottom: 1px solid var(--km-g200);
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<div class="km-ci" style="background: #fef3c7; color: #d97706">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</div>
|
||||
<div class="km-ct">Exceptions Register</div>
|
||||
</div>
|
||||
|
||||
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px">
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'pending'"
|
||||
(click)="activeExceptionSubTab = 'pending'"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||
>
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
Pending Exceptions ({{ pendingExceptions.length }})
|
||||
</button>
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'resolved'"
|
||||
(click)="activeExceptionSubTab = 'resolved'"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Resolved Exceptions ({{ resolvedExceptions.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PENDING EXCEPTIONS TAB -->
|
||||
@if (activeExceptionSubTab === 'pending') {
|
||||
@if (pendingExceptions.length === 0) {
|
||||
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||
<div class="km-empty-i">🎉</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px">All exceptions resolved!</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||
There are no active client KYC exceptions on record.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="km-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client / Account</th>
|
||||
<th>Branch</th>
|
||||
<th>Exception Reason & Date</th>
|
||||
<th>Risk</th>
|
||||
<th>Contact Status</th>
|
||||
<th>Contact Logs</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of pendingExceptions; track c.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div
|
||||
class="km-an"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||
>
|
||||
{{ c.name }}
|
||||
</div>
|
||||
<div class="km-ai">{{ c.account }}</div>
|
||||
</td>
|
||||
<td>{{ c.contact.branch }}</td>
|
||||
<td>
|
||||
<div style="font-weight: 500; color: #b45309">{{ c.exceptionReason }}</div>
|
||||
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<span class="km-del d-ok">Contacted</span>
|
||||
<div class="km-small" style="margin-top: 2px">
|
||||
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="km-del d-fa">Not Contacted</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||
@for (log of c.contactHistory; track $index) {
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||
<em>— {{ log.officer }}</em>
|
||||
@if (log.platformSent) {
|
||||
@if (log.delivered) {
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||
>Sent {{ log.destinationMasked }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button
|
||||
class="km-btn km-btn-ot"
|
||||
style="border-color: var(--km-blue); color: var(--km-blue)"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
>
|
||||
<fa-icon icon="user-check"></fa-icon> Do KYC
|
||||
</button>
|
||||
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
|
||||
<fa-icon icon="phone"></fa-icon> Log Call
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- RESOLVED EXCEPTIONS TAB -->
|
||||
@if (activeExceptionSubTab === 'resolved') {
|
||||
@if (resolvedExceptions.length === 0) {
|
||||
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1)">📋</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500)">
|
||||
No resolved exceptions
|
||||
</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||
Resolved KYC exceptions will appear here once verification is completed.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="km-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client / Account</th>
|
||||
<th>Branch</th>
|
||||
<th>Exception Reason & Resolution</th>
|
||||
<th>Risk</th>
|
||||
<th>Contact Logs</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of resolvedExceptions; track c.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div
|
||||
class="km-an"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||
>
|
||||
{{ c.name }}
|
||||
</div>
|
||||
<div class="km-ai">{{ c.account }}</div>
|
||||
</td>
|
||||
<td>{{ c.contact.branch }}</td>
|
||||
<td>
|
||||
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through">
|
||||
{{ c.exceptionReason }}
|
||||
</div>
|
||||
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||
@for (log of c.contactHistory; track $index) {
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||
<em>— {{ log.officer }}</em>
|
||||
@if (log.platformSent) {
|
||||
@if (log.delivered) {
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||
>Sent {{ log.destinationMasked }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CLIENT DETAIL MODAL -->
|
||||
@@ -1165,6 +1448,251 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LOG CONTACT DIALOG -->
|
||||
@if (contactDialogOpen && current) {
|
||||
<div class="km-overlay" (click)="closeContactDialog()">
|
||||
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||
<div class="km-mh">
|
||||
<div class="km-mh-av">{{ current.av }}</div>
|
||||
<div>
|
||||
<div class="km-mh-name">Log Customer Contact</div>
|
||||
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||
</div>
|
||||
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
|
||||
</div>
|
||||
|
||||
<div class="km-mbody hv-body">
|
||||
<div class="hv-addr">
|
||||
<div class="hv-addr-label">Customer Contact Info</div>
|
||||
<div class="hv-addr-name">{{ current.name }}</div>
|
||||
<div class="hv-addr-text">
|
||||
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
|
||||
</div>
|
||||
<div class="hv-addr-text">
|
||||
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
|
||||
<select class="hv-input" [(ngModel)]="contactChannel">
|
||||
@for (ch of contactChannels; track ch.value) {
|
||||
<option [value]="ch.value">
|
||||
{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (selectedChannelSends) {
|
||||
<div class="hv-hint hv-hint-send">
|
||||
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
|
||||
A KYC notice will be sent to the customer when you save.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="hv-hint">Records an action you already took. Nothing is sent to the customer.</div>
|
||||
}
|
||||
@if (selectedChannelUnavailable) {
|
||||
<div class="hv-hint hv-hint-error">
|
||||
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on
|
||||
file — choose a different channel.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
|
||||
<select class="hv-input" [(ngModel)]="contactOfficer">
|
||||
@for (officer of bankOfficers; track officer) {
|
||||
<option [value]="officer">{{ officer }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
|
||||
<textarea
|
||||
class="hv-input hv-textarea"
|
||||
rows="3"
|
||||
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
|
||||
[(ngModel)]="contactNotes"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@if (contactError) {
|
||||
<div class="hv-hint hv-hint-error" role="alert">
|
||||
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hv-footer">
|
||||
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
|
||||
<button class="km-btn km-btn-t" [disabled]="contactSaveDisabled" (click)="saveContactLog()">
|
||||
{{ contactSaveLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- RESOLVE KYC DIALOG -->
|
||||
@if (resolveDialogOpen && current) {
|
||||
<div class="km-overlay" (click)="closeResolveDialog()">
|
||||
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||
<div class="km-mh" style="border-bottom: 2px solid var(--km-green)">
|
||||
<div class="km-mh-av" style="background: var(--km-green)">{{ current.av }}</div>
|
||||
<div>
|
||||
<div class="km-mh-name">Resolve KYC Exception</div>
|
||||
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||
</div>
|
||||
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
|
||||
</div>
|
||||
|
||||
<div class="km-mbody hv-body">
|
||||
<div
|
||||
style="
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
"
|
||||
>
|
||||
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
|
||||
@if (current.exceptionRemarks) {
|
||||
<div style="margin-top: 4px; font-style: italic">"{{ current.exceptionRemarks }}"</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showPanResolution) {
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
|
||||
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve PAN Card Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolvePanActive) {
|
||||
<div class="hv-row" style="margin-left: 20px">
|
||||
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolvePanNumber"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10"
|
||||
style="text-transform: uppercase"
|
||||
/>
|
||||
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px">PAN must be exactly 10 characters.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showAadhaarResolution) {
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
|
||||
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve Aadhaar Card Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolveAadhaarActive) {
|
||||
<div class="hv-row" style="margin-left: 20px">
|
||||
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolveAadhaarNumber"
|
||||
placeholder="e.g. 597855025299"
|
||||
maxlength="12"
|
||||
/>
|
||||
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px">Aadhaar must be exactly 12 digits.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showGeneralResolution) {
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
|
||||
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve General Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolveGeneralActive) {
|
||||
<div style="margin-left: 20px">
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
|
||||
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
|
||||
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolveGeneralDocumentNumber"
|
||||
placeholder="Enter verified document number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
|
||||
<textarea
|
||||
class="hv-input hv-textarea"
|
||||
rows="3"
|
||||
placeholder="Describe how verification was resolved..."
|
||||
[(ngModel)]="resolveRemarks"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hv-footer">
|
||||
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
|
||||
<button class="km-btn km-btn-g" [disabled]="!isResolveValid" (click)="saveResolveKyc()">
|
||||
Complete KYC & Resolve Exception
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1588,6 +1588,58 @@
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
|
||||
the -send and -error variants call out the two cases the officer must not misread. */
|
||||
.hv-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--km-g500);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hv-hint-send {
|
||||
color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
border: 1px solid #99f6e4;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hv-hint-error {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
|
||||
different facts, so they never share a colour. */
|
||||
.km-sent-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.km-sent-ok {
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
}
|
||||
|
||||
.km-sent-fail {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.hv-footer {
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--km-g200);
|
||||
@@ -1648,3 +1700,30 @@
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* KYC Exceptions Styles */
|
||||
.km-ci-e {
|
||||
background: var(--km-amber-l) !important;
|
||||
color: var(--km-amber) !important;
|
||||
}
|
||||
|
||||
.contact-logs-list {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-family: inherit;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit } 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 { ClientsService } from '../clients/clients.service';
|
||||
|
||||
interface KmNotice {
|
||||
no: string;
|
||||
@@ -31,6 +32,24 @@ interface KmEvidence {
|
||||
docket?: string;
|
||||
}
|
||||
|
||||
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
|
||||
interface KmContactLog {
|
||||
date: string;
|
||||
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
|
||||
action: string;
|
||||
notes: string;
|
||||
officer: string;
|
||||
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
|
||||
platformSent?: boolean;
|
||||
/** True only when the notice actually reached the gateway. */
|
||||
delivered?: boolean;
|
||||
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
|
||||
deliveryStatus?: string;
|
||||
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
|
||||
deliveryMessage?: string;
|
||||
destinationMasked?: string;
|
||||
}
|
||||
|
||||
interface KmClient {
|
||||
id: string;
|
||||
av: string;
|
||||
@@ -66,6 +85,17 @@ interface KmClient {
|
||||
pendingActions: string[];
|
||||
actionTaken: string[];
|
||||
evidence: KmEvidence | null;
|
||||
isException?: boolean;
|
||||
exceptionReason?: string;
|
||||
exceptionRemarks?: string;
|
||||
exceptionDate?: string;
|
||||
exceptionResolved?: boolean;
|
||||
exceptionResolvedOn?: string;
|
||||
contactHistory?: KmContactLog[];
|
||||
kycId?: string;
|
||||
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
|
||||
hasMobile?: boolean;
|
||||
hasEmail?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -79,7 +109,8 @@ interface KmClient {
|
||||
]
|
||||
})
|
||||
export class KycManagementComponent implements OnInit {
|
||||
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
|
||||
private clientsService = inject(ClientsService);
|
||||
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
|
||||
|
||||
modalOpen = false;
|
||||
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
|
||||
@@ -529,15 +560,419 @@ export class KycManagementComponent implements OnInit {
|
||||
};
|
||||
|
||||
alertRows: KmClient[] = [];
|
||||
exceptionRows: KmClient[] = [];
|
||||
pendingExceptions: KmClient[] = [];
|
||||
resolvedExceptions: KmClient[] = [];
|
||||
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
|
||||
selectedExceptionReason = '';
|
||||
selectedExceptionContactStatus = '';
|
||||
|
||||
// Dialog properties for exceptions
|
||||
contactDialogOpen = false;
|
||||
contactChannel = 'PHONE_CALL';
|
||||
contactNotes = '';
|
||||
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
|
||||
/** In-flight guard — a double-click must not send the customer two notices. */
|
||||
contactSaving = false;
|
||||
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
|
||||
contactError = '';
|
||||
|
||||
/**
|
||||
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
|
||||
* transmits a real notice to the customer, as opposed to recording an action the officer already
|
||||
* took offline — the dialog labels and the confirm button both key off it.
|
||||
*/
|
||||
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
|
||||
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
|
||||
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
|
||||
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
|
||||
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
|
||||
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
|
||||
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
|
||||
];
|
||||
|
||||
resolveDialogOpen = false;
|
||||
resolveDocumentType = 'PAN';
|
||||
resolveDocumentNumber = '';
|
||||
resolveRemarks = '';
|
||||
|
||||
resolvePanActive = false;
|
||||
resolvePanNumber = '';
|
||||
resolveAadhaarActive = false;
|
||||
resolveAadhaarNumber = '';
|
||||
resolveGeneralActive = false;
|
||||
resolveGeneralDocumentType = 'OVD';
|
||||
resolveGeneralDocumentNumber = '';
|
||||
|
||||
showPanResolution = false;
|
||||
showAadhaarResolution = false;
|
||||
showGeneralResolution = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertRows = Object.values(this.clients);
|
||||
this.loadExceptions();
|
||||
}
|
||||
|
||||
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
|
||||
loadExceptions(): void {
|
||||
this.clientsService.getKycExceptions(false).subscribe({
|
||||
next: (data) => {
|
||||
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
|
||||
this.exceptionRows = this.pendingExceptions;
|
||||
this.loadContactHistories(this.pendingExceptions);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load active exceptions', err);
|
||||
this.pendingExceptions = [];
|
||||
this.exceptionRows = [];
|
||||
}
|
||||
});
|
||||
|
||||
this.clientsService.getKycExceptions(true).subscribe({
|
||||
next: (data) => {
|
||||
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
|
||||
this.loadContactHistories(this.resolvedExceptions);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load resolved exceptions', err);
|
||||
this.resolvedExceptions = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private mapException(item: any, resolved: boolean): KmClient {
|
||||
return {
|
||||
id: item.clientId?.toString(),
|
||||
av: (item.clientDisplayName || 'Client')
|
||||
.split(' ')
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase(),
|
||||
name: item.clientDisplayName,
|
||||
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
|
||||
account: item.clientAccountNo || `CLI-${item.clientId}`,
|
||||
state: 'alert',
|
||||
risk: item.riskCategoryCode?.toLowerCase() || 'low',
|
||||
riskLabel: item.riskCategory || 'Low Risk',
|
||||
expiry: 'Pending',
|
||||
daysLeft: 30,
|
||||
kyc: {
|
||||
type: item.kycType || 'Individual',
|
||||
initiated: item.initiatedOn || '—',
|
||||
lastVerified: item.verifiedOn || '—',
|
||||
validity: '—',
|
||||
pan: item.panNumber || '—',
|
||||
aadhaar: item.aadhaarNumberMasked || '—',
|
||||
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
|
||||
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
|
||||
panLink: 'Not Linked'
|
||||
},
|
||||
contact: {
|
||||
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
|
||||
// here — it's why an SMS or Email channel will be unavailable for this customer.
|
||||
mobile: item.clientMobileMasked || 'Not on file',
|
||||
email: item.clientEmailMasked || 'Not on file',
|
||||
address: 'Client Address, Bhopal MP 462002',
|
||||
branch: item.clientOfficeName || 'Bhopal Main'
|
||||
},
|
||||
notices: [],
|
||||
noticesSent: 0,
|
||||
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
|
||||
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
|
||||
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
|
||||
actionTaken: resolved ? ['KYC Exception resolved'] : [],
|
||||
evidence: null,
|
||||
isException: true,
|
||||
// No invented default — a record whose reason was never stored (or was cleared by the old
|
||||
// resolve path) must not be relabelled as a PAN failure it may never have had.
|
||||
exceptionReason: item.exceptionReason || 'Reason not recorded',
|
||||
exceptionRemarks: item.remarks || '',
|
||||
exceptionDate: item.initiatedOn || '—',
|
||||
exceptionResolved: resolved,
|
||||
exceptionResolvedOn: item.exceptionResolvedOn || '—',
|
||||
contactHistory: [],
|
||||
kycId: item.id?.toString(),
|
||||
hasMobile: item.hasMobile === true,
|
||||
hasEmail: item.hasEmail === true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
|
||||
* one row leaves that row's history empty rather than blanking the whole grid.
|
||||
*/
|
||||
private loadContactHistories(rows: KmClient[]): void {
|
||||
for (const row of rows) {
|
||||
if (!row.id) continue;
|
||||
this.clientsService.getKycContactLog(row.id).subscribe({
|
||||
next: (logs) => {
|
||||
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Failed to load contact log for client ${row.id}`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get notYetContactedCount(): number {
|
||||
return this.exceptionRows.filter((c) => !c.contactHistory || c.contactHistory.length === 0).length;
|
||||
}
|
||||
|
||||
get panMismatchesCount(): number {
|
||||
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('PAN')).length;
|
||||
}
|
||||
|
||||
get aadhaarFailuresCount(): number {
|
||||
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length;
|
||||
}
|
||||
|
||||
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
|
||||
this.activeSection = section;
|
||||
}
|
||||
|
||||
openContactDialog(client: KmClient): void {
|
||||
this.current = client;
|
||||
this.contactChannel = 'PHONE_CALL';
|
||||
this.contactNotes = '';
|
||||
this.contactError = '';
|
||||
this.contactSaving = false;
|
||||
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
|
||||
this.contactDialogOpen = true;
|
||||
}
|
||||
|
||||
closeContactDialog(): void {
|
||||
this.contactDialogOpen = false;
|
||||
}
|
||||
|
||||
/** The channel currently picked in the dialog. */
|
||||
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
|
||||
return this.contactChannels.find((c) => c.value === this.contactChannel);
|
||||
}
|
||||
|
||||
/** True when saving will transmit a notice to the customer, not merely record one. */
|
||||
get selectedChannelSends(): boolean {
|
||||
return this.selectedChannel?.sends === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
|
||||
* the officer isn't left reading a failure they could have seen up front.
|
||||
*/
|
||||
get selectedChannelUnavailable(): boolean {
|
||||
const requires = this.selectedChannel?.requires;
|
||||
if (!requires || !this.current) return false;
|
||||
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
|
||||
}
|
||||
|
||||
get contactSaveDisabled(): boolean {
|
||||
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
|
||||
}
|
||||
|
||||
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
|
||||
get contactSaveLabel(): string {
|
||||
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
|
||||
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
|
||||
}
|
||||
|
||||
saveContactLog(): void {
|
||||
if (!this.current || this.contactSaveDisabled) return;
|
||||
|
||||
const client = this.current;
|
||||
this.contactSaving = true;
|
||||
this.contactError = '';
|
||||
|
||||
this.clientsService
|
||||
.saveKycContactLog(client.id, {
|
||||
channel: this.contactChannel,
|
||||
notes: this.contactNotes.trim(),
|
||||
officerName: this.contactOfficer
|
||||
})
|
||||
.subscribe({
|
||||
next: (log: any) => {
|
||||
this.contactSaving = false;
|
||||
client.contactHistory = [
|
||||
...(client.contactHistory || []),
|
||||
this.mapContactLog(log)
|
||||
];
|
||||
|
||||
// The request succeeding does not mean the customer was reached — a gateway failure comes
|
||||
// back as HTTP 200 with delivered=false, so report what actually happened.
|
||||
if (log?.platformSent && !log?.delivered) {
|
||||
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
|
||||
} else {
|
||||
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
|
||||
}
|
||||
this.contactDialogOpen = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.contactSaving = false;
|
||||
// Keep the dialog open — the officer's notes are still in the textarea and would
|
||||
// otherwise be lost.
|
||||
this.contactError =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'Could not save the contact log. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private mapContactLog(log: any): KmContactLog {
|
||||
return {
|
||||
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
|
||||
action: log?.channelLabel || log?.channel || 'Contact',
|
||||
notes: log?.notes || '',
|
||||
officer: (log?.officerName || '').split(' — ')[0],
|
||||
platformSent: log?.platformSent,
|
||||
delivered: log?.delivered,
|
||||
deliveryStatus: log?.deliveryStatus,
|
||||
deliveryMessage: log?.deliveryMessage,
|
||||
destinationMasked: log?.destinationMasked
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
|
||||
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
|
||||
*/
|
||||
private formatLogDate(value: any): string {
|
||||
const date = Array.isArray(value)
|
||||
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
|
||||
: new Date(value);
|
||||
if (isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
get isResolveValid(): boolean {
|
||||
if (!this.resolveRemarks?.trim()) return false;
|
||||
let anyActive = false;
|
||||
if (this.showPanResolution && this.resolvePanActive) {
|
||||
anyActive = true;
|
||||
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
|
||||
}
|
||||
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
|
||||
anyActive = true;
|
||||
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
|
||||
}
|
||||
if (this.showGeneralResolution && this.resolveGeneralActive) {
|
||||
anyActive = true;
|
||||
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
|
||||
}
|
||||
return anyActive;
|
||||
}
|
||||
|
||||
openResolveDialog(client: KmClient): void {
|
||||
this.current = client;
|
||||
const reason = client.exceptionReason || '';
|
||||
this.showPanResolution = reason.includes('PAN');
|
||||
this.showAadhaarResolution = reason.includes('Aadhaar');
|
||||
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
|
||||
|
||||
this.resolvePanActive = this.showPanResolution;
|
||||
this.resolveAadhaarActive = this.showAadhaarResolution;
|
||||
this.resolveGeneralActive = this.showGeneralResolution;
|
||||
|
||||
this.resolvePanNumber = '';
|
||||
this.resolveAadhaarNumber = '';
|
||||
this.resolveGeneralDocumentType = 'OVD';
|
||||
this.resolveGeneralDocumentNumber = '';
|
||||
this.resolveRemarks = '';
|
||||
this.resolveDialogOpen = true;
|
||||
}
|
||||
|
||||
closeResolveDialog(): void {
|
||||
this.resolveDialogOpen = false;
|
||||
}
|
||||
|
||||
saveResolveKyc(): void {
|
||||
if (!this.current || !this.current.kycId) return;
|
||||
const clientId = this.current.id;
|
||||
const kycId = this.current.kycId;
|
||||
|
||||
const today = new Date();
|
||||
const day = today.getDate().toString().padStart(2, '0');
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
const monthStr = months[today.getMonth()];
|
||||
const year = today.getFullYear();
|
||||
const dateStr = `${day} ${monthStr} ${year}`;
|
||||
|
||||
const resolvingPan = this.showPanResolution && this.resolvePanActive;
|
||||
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
|
||||
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
|
||||
|
||||
const remainsPan = this.showPanResolution && !this.resolvePanActive;
|
||||
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
|
||||
|
||||
const isFullyResolved = !remainsPan && !remainsAadhaar;
|
||||
|
||||
const resolvedKycData: any = {
|
||||
locale: 'en',
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
|
||||
};
|
||||
|
||||
if (isFullyResolved) {
|
||||
resolvedKycData.isException = false;
|
||||
resolvedKycData.exceptionResolved = true;
|
||||
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
|
||||
// the audit record of why the exception was ever raised.
|
||||
} else {
|
||||
resolvedKycData.isException = true;
|
||||
resolvedKycData.exceptionResolved = false;
|
||||
const remainingReasons: string[] = [];
|
||||
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
|
||||
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
|
||||
resolvedKycData.exceptionReason = remainingReasons.join(', ');
|
||||
}
|
||||
|
||||
if (resolvingPan) {
|
||||
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
|
||||
resolvedKycData.panVerified = true;
|
||||
}
|
||||
if (resolvingAadhaar) {
|
||||
const aadhaarRaw = this.resolveAadhaarNumber.trim();
|
||||
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
|
||||
resolvedKycData.aadhaarVerified = true;
|
||||
}
|
||||
if (resolvingGeneral) {
|
||||
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
|
||||
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
|
||||
resolvedKycData.isException = false;
|
||||
resolvedKycData.exceptionResolved = true;
|
||||
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||
// exceptionReason preserved — see the fully-resolved branch above.
|
||||
}
|
||||
|
||||
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
|
||||
next: () => {
|
||||
const msg = isFullyResolved
|
||||
? `KYC Exception fully resolved and completed for ${this.current?.name}`
|
||||
: `KYC Exception partially resolved for ${this.current?.name}`;
|
||||
this.toast(msg);
|
||||
this.loadExceptions();
|
||||
this.resolveDialogOpen = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to resolve exception', err);
|
||||
this.toast('Failed to resolve exception. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openModal(id: string): void {
|
||||
const c = this.clients[id];
|
||||
if (!c) return;
|
||||
|
||||
@@ -505,3 +505,72 @@
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--cert-g900);
|
||||
}
|
||||
|
||||
/* Form 121 register — eligibility warnings and the quarter-wise drill-down */
|
||||
|
||||
/* A row the branch needs to look at: an ineligible declaration, or the quarter
|
||||
in which the 194A threshold was crossed. */
|
||||
.cert-row--warn > td {
|
||||
background: color-mix(in srgb, var(--cert-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.cert-row--detail > td {
|
||||
background: var(--cert-g50);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cert-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||
border-left: 3px solid var(--cert-blue);
|
||||
font-size: 12px;
|
||||
color: var(--cert-g900);
|
||||
line-height: 1.5;
|
||||
|
||||
fa-icon {
|
||||
color: var(--cert-blue);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: color-mix(in srgb, var(--cert-amber) 8%, transparent);
|
||||
border-left-color: var(--cert-amber);
|
||||
|
||||
fa-icon {
|
||||
color: var(--cert-amber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The quarter table nested inside an expanded row. */
|
||||
.cert-table--inner {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cert-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
th {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--cert-g200);
|
||||
}
|
||||
|
||||
.cert-pill {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,158 +11,347 @@
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Form 121</h2>
|
||||
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
|
||||
<h2 class="cert-head-t">{{ statute.form }}</h2>
|
||||
<div class="cert-head-s">
|
||||
@if (viewMode === 'demo') {
|
||||
Declarations to stop tax being deducted on fixed and recurring deposit interest under
|
||||
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
|
||||
uploaded {{ statute.frequency }}.
|
||||
} @else {
|
||||
Live customer declarations, sourced from client accounts and KYC profiles.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
@if (viewMode === 'demo') {
|
||||
<button
|
||||
type="button"
|
||||
class="cert-cta-btn"
|
||||
(click)="toggleViewMode()"
|
||||
matTooltip="Use real client KYC and account data from Fineract"
|
||||
>
|
||||
<fa-icon icon="database"></fa-icon> Real Declarations
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="cert-cta-btn cert-cta-btn--muted"
|
||||
(click)="toggleViewMode()"
|
||||
matTooltip="Back to the sample data page"
|
||||
>
|
||||
<fa-icon icon="table"></fa-icon> Sample Data
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
||||
<div class="cert-kpi-l">Form 121 (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
||||
<div class="cert-kpi-l">Form 121 (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (viewMode === 'real') {
|
||||
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
||||
@if (viewMode === 'demo') {
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Form Type</label>
|
||||
<select [(ngModel)]="kindFilter">
|
||||
<option value="">All Form 121</option>
|
||||
<option value="15G">Form 121 (Below 60 years)</option>
|
||||
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
||||
</select>
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="times"></fa-icon> Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || kindFilter || statusFilter) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form Type</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Deposit Principal</th>
|
||||
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRecords; track r.customerId) {
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ r.customerName }}</div>
|
||||
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
||||
>
|
||||
Form 121 ({{ formKind(r) }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
||||
<td class="small">{{ r.branch }}</td>
|
||||
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
@if (r.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (r.filedOn) {
|
||||
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||
>
|
||||
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ row.age }} yrs</td>
|
||||
<td class="small">{{ row.record.branch }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(r)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(r)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (filteredRecords.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No declarations match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-table--inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of row.schedule.lines; track line.quarter) {
|
||||
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No declarations match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,31 @@ import {
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm15ghPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
|
||||
|
||||
export type Form15Kind = '15G' | '15H';
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
Form15Kind,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
isSeniorInFy,
|
||||
normaliseFy,
|
||||
projectQuarterlyCredits,
|
||||
tdsModeFor
|
||||
} from './tds-15gh.compute';
|
||||
|
||||
export type { Form15Kind };
|
||||
export type Form15Status = 'FILED' | 'PENDING';
|
||||
/** Which deduction trigger the payee falls under. Age-based, still. */
|
||||
export type ThresholdBand = 'GENERAL' | 'SENIOR';
|
||||
|
||||
export interface Form15ghRecord {
|
||||
customerId: string;
|
||||
@@ -32,18 +54,46 @@ export interface Form15ghRecord {
|
||||
customerAddress: string;
|
||||
dob: string;
|
||||
branch: string;
|
||||
/** Aggregate interest the bank will pay this FY. */
|
||||
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */
|
||||
estimatedInterest: number;
|
||||
/** Customer's total estimated income for the FY. */
|
||||
/** Customer's total estimated income for the FY, as declared by them. */
|
||||
estimatedIncome: number;
|
||||
/** Sum of FD / RD principals tied to the declaration. */
|
||||
depositPrincipal: number;
|
||||
fy: string;
|
||||
status: Form15Status;
|
||||
filedOn?: string;
|
||||
/** "Yes / No" for: has any income tax been assessed for the customer
|
||||
under the IT Act in the past six years? */
|
||||
/**
|
||||
* "Yes / No" for: has any income tax been assessed for the customer under the
|
||||
* IT Act in the past six years?
|
||||
*/
|
||||
assessed: boolean;
|
||||
/**
|
||||
* The deposit product's interest-posting frequency. Tax falls due when interest
|
||||
* is credited, so this — and not any customer preference — decides whether it
|
||||
* settles quarterly or at year end.
|
||||
*/
|
||||
interestPostingPeriodType: string;
|
||||
}
|
||||
|
||||
/** A register row with everything the engine derived for it, computed once. */
|
||||
export interface Form15ghRow {
|
||||
record: Form15ghRecord;
|
||||
/** The form actually in force for the record's year — '121' from FY 2026-27. */
|
||||
form: DeclarationForm;
|
||||
/**
|
||||
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
|
||||
* the forms merged and this reflects the threshold band alone.
|
||||
*/
|
||||
kind: Form15Kind;
|
||||
band: ThresholdBand;
|
||||
age: number;
|
||||
mode: TdsMode;
|
||||
/** Schedule as things stand, declaration included where one is on record. */
|
||||
schedule: TdsSchedule;
|
||||
/** Tax the declaration kept out of the customer's pocket this year. */
|
||||
tdsSaved: number;
|
||||
eligibility: EligibilityResult;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -55,7 +105,8 @@ export interface Form15ghRecord {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
MatTooltip,
|
||||
RealForm15ghViewComponent
|
||||
]
|
||||
})
|
||||
export class Form15ghComponent implements OnInit {
|
||||
@@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit {
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
|
||||
viewMode: 'demo' | 'real' = 'demo';
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
/** Which form. Empty = both. */
|
||||
kindFilter: '' | Form15Kind = '';
|
||||
/** Which deduction trigger. Empty = both bands. */
|
||||
bandFilter: '' | ThresholdBand = '';
|
||||
statusFilter: '' | Form15Status = '';
|
||||
/** Show only declarations the branch cannot lawfully accept. */
|
||||
ineligibleOnly = false;
|
||||
|
||||
records: Form15ghRecord[] = [];
|
||||
/** Which row's quarter-wise breakdown is open. */
|
||||
expandedId: string | null = null;
|
||||
|
||||
rows: Form15ghRow[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.records = seedForm15gh();
|
||||
// Derived once. The template reads these many times per change-detection
|
||||
// pass, and re-running the schedules on every read is what made the other
|
||||
// register screens stall.
|
||||
this.rows = seedForm15gh().map((record) => this.buildRow(record));
|
||||
}
|
||||
|
||||
/** Compute age in years from DoB. */
|
||||
age(dob: string): number {
|
||||
if (!dob) return 0;
|
||||
const today = new Date();
|
||||
const birth = new Date(dob);
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age;
|
||||
private buildRow(record: Form15ghRecord): Form15ghRow {
|
||||
const ctx = {
|
||||
fy: record.fy,
|
||||
dateOfBirth: record.dob,
|
||||
pan: record.customerPan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
const declaration: Declaration | null =
|
||||
record.status === 'FILED'
|
||||
? {
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
// The register holds the filing date; a declaration operates from
|
||||
// the day the branch accepted it.
|
||||
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||
fy: record.fy
|
||||
}
|
||||
: null;
|
||||
|
||||
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||
// The register carries a projected annual figure rather than real postings,
|
||||
// so spread it across the year to drive the schedule.
|
||||
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||
|
||||
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
|
||||
|
||||
return {
|
||||
record,
|
||||
form: declarationFormFor(record.fy, record.dob),
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||
age: ageInFy(record.dob, record.fy),
|
||||
mode,
|
||||
schedule,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
eligibility: checkEligibility(ctx, {
|
||||
estimatedInterest: record.estimatedInterest,
|
||||
estimatedIncome: record.estimatedIncome,
|
||||
incomeOnRecord: true
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** 15H once 60+, 15G otherwise. */
|
||||
formKind(r: Form15ghRecord): Form15Kind {
|
||||
return this.age(r.dob) >= 60 ? '15H' : '15G';
|
||||
}
|
||||
|
||||
get filteredRecords(): Form15ghRecord[] {
|
||||
get filteredRows(): Form15ghRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.records.filter((r) => {
|
||||
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
|
||||
if (this.statusFilter && r.status !== this.statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return this.rows.filter((row) => {
|
||||
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
const r = row.record;
|
||||
return (
|
||||
r.customerName.toLowerCase().includes(q) ||
|
||||
r.customerId.toLowerCase().includes(q) ||
|
||||
@@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
count15G(): number {
|
||||
return this.records.filter((r) => this.formKind(r) === '15G').length;
|
||||
countGeneralBand(): number {
|
||||
return this.rows.filter((r) => r.band === 'GENERAL').length;
|
||||
}
|
||||
|
||||
count15H(): number {
|
||||
return this.records.filter((r) => this.formKind(r) === '15H').length;
|
||||
countSeniorBand(): number {
|
||||
return this.rows.filter((r) => r.band === 'SENIOR').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declaration in force across the register, and the provision charging the
|
||||
* tax. Both changed on 01 April 2026, so they are read from the config rather
|
||||
* than written into the template.
|
||||
*/
|
||||
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
|
||||
const cfg = FY_TAX_CONFIG[fy];
|
||||
return {
|
||||
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||
section: cfg.interestTdsSection,
|
||||
statement: cfg.tdsStatementForm,
|
||||
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
countFiled(): number {
|
||||
return this.records.filter((r) => r.status === 'FILED').length;
|
||||
return this.rows.filter((r) => r.record.status === 'FILED').length;
|
||||
}
|
||||
|
||||
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||
countIneligible(): number {
|
||||
return this.rows.filter((r) => !r.eligibility.eligible).length;
|
||||
}
|
||||
|
||||
totalInterest(): number {
|
||||
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
|
||||
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0);
|
||||
}
|
||||
|
||||
/** Tax the declarations kept out of customers' pockets, across the filter. */
|
||||
totalTdsSaved(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.kindFilter = '';
|
||||
this.bandFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.ineligibleOnly = false;
|
||||
}
|
||||
|
||||
viewCertificate(r: Form15ghRecord): void {
|
||||
toggleExpanded(row: Form15ghRow): void {
|
||||
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
|
||||
}
|
||||
|
||||
isExpanded(row: Form15ghRow): boolean {
|
||||
return this.expandedId === row.record.customerId;
|
||||
}
|
||||
|
||||
viewCertificate(row: Form15ghRow): void {
|
||||
const data: CertificatePreviewDialogData = {
|
||||
kind: 'form15gh',
|
||||
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
|
||||
form15gh: { record: row.record, kind: row.kind, age: row.age }
|
||||
};
|
||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||
width: '880px',
|
||||
@@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
downloadCertificate(r: Form15ghRecord): void {
|
||||
const kind = this.formKind(r);
|
||||
downloadCertificate(row: Form15ghRow): void {
|
||||
const r = row.record;
|
||||
// A declaration the branch cannot accept must not go out as a signed PDF.
|
||||
if (!row.eligibility.eligible) {
|
||||
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||
return;
|
||||
}
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `Form 121 (${kind}) Declaration`,
|
||||
title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`,
|
||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
||||
(dsc) => downloadForm15ghPdf(r, row.kind, dsc)
|
||||
);
|
||||
}
|
||||
|
||||
toggleViewMode(): void {
|
||||
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────── Mock seed (top-level export) ─────────────────────
|
||||
// ───────────────────────── Mock seed (top-level export) ─────────────────────
|
||||
/**
|
||||
* Two of these customers are deliberately ineligible — their estimated income
|
||||
* leaves a tax liability, so a Form 121 declaration cannot be accepted from them.
|
||||
* That gives the register's eligibility warnings something to show.
|
||||
*/
|
||||
export function seedForm15gh(): Form15ghRecord[] {
|
||||
return [
|
||||
{
|
||||
@@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] {
|
||||
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
|
||||
dob: '1985-02-14',
|
||||
branch: 'Mumbai — Andheri (E)',
|
||||
estimatedInterest: 38500,
|
||||
estimatedIncome: 850000,
|
||||
depositPrincipal: 500000,
|
||||
fy: 'FY 2025-26',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-10',
|
||||
assessed: true
|
||||
estimatedInterest: 38_500,
|
||||
// Over the basic exemption limit — not eligible for 15G.
|
||||
estimatedIncome: 850_000,
|
||||
depositPrincipal: 500_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST556677',
|
||||
customerName: 'Karthik Rao',
|
||||
customerPan: 'PQRSR2233Z',
|
||||
customerPan: 'PQRPR2233Z',
|
||||
customerAddress: '24, MG Road, Bengaluru 560001',
|
||||
dob: '1972-09-04',
|
||||
branch: 'Bengaluru — MG Road',
|
||||
estimatedInterest: 52800,
|
||||
estimatedIncome: 320000,
|
||||
depositPrincipal: 700000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 52_800,
|
||||
estimatedIncome: 320_000,
|
||||
depositPrincipal: 700_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: false
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST667788',
|
||||
customerName: 'Lakshmi Naidu',
|
||||
customerPan: 'XYZAN7788Q',
|
||||
customerPan: 'XYZPN7788Q',
|
||||
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
|
||||
dob: '1958-06-22',
|
||||
branch: 'Hyderabad — Banjara Hills',
|
||||
estimatedInterest: 88200,
|
||||
estimatedIncome: 410000,
|
||||
depositPrincipal: 1200000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 88_200,
|
||||
estimatedIncome: 410_000,
|
||||
depositPrincipal: 1_200_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-15',
|
||||
assessed: true
|
||||
filedOn: '2026-04-15',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST334455',
|
||||
customerName: 'Anita Verma',
|
||||
customerPan: 'EFGHV5678A',
|
||||
customerPan: 'EFGPV5678A',
|
||||
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
|
||||
dob: '1962-11-30',
|
||||
branch: 'New Delhi — Connaught Place',
|
||||
estimatedInterest: 64500,
|
||||
estimatedIncome: 280000,
|
||||
depositPrincipal: 900000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 64_500,
|
||||
estimatedIncome: 280_000,
|
||||
depositPrincipal: 900_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-05',
|
||||
assessed: false
|
||||
filedOn: '2026-04-05',
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST990011',
|
||||
customerName: 'Arjun Banerjee',
|
||||
customerPan: 'TUVWB4455F',
|
||||
customerPan: 'TUVPB4455F',
|
||||
customerAddress: '42, Park Street, Kolkata 700016',
|
||||
dob: '1991-07-19',
|
||||
branch: 'Kolkata — Park Street',
|
||||
estimatedInterest: 14200,
|
||||
estimatedIncome: 620000,
|
||||
depositPrincipal: 200000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 14_200,
|
||||
// Over the basic exemption limit — not eligible for 15G.
|
||||
estimatedIncome: 620_000,
|
||||
depositPrincipal: 200_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST330044',
|
||||
customerName: 'Madhavi Singh',
|
||||
customerPan: 'TUVWS3344K',
|
||||
customerPan: 'TUVPS3344K',
|
||||
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
|
||||
dob: '1955-03-08',
|
||||
branch: 'New Delhi — Connaught Place',
|
||||
estimatedInterest: 145000,
|
||||
estimatedIncome: 320000,
|
||||
depositPrincipal: 2000000,
|
||||
fy: 'FY 2025-26',
|
||||
// A 15H declarant faces no ceiling on interest — only a nil liability.
|
||||
estimatedInterest: 145_000,
|
||||
estimatedIncome: 320_000,
|
||||
depositPrincipal: 2_000_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-02',
|
||||
assessed: false
|
||||
filedOn: '2026-04-02',
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST778899',
|
||||
customerName: 'Vikram Malhotra',
|
||||
customerPan: '',
|
||||
customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103',
|
||||
dob: '1980-05-15',
|
||||
branch: 'Bengaluru — MG Road',
|
||||
estimatedInterest: 60_000,
|
||||
estimatedIncome: 350_000,
|
||||
depositPrincipal: 800_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (loading) {
|
||||
<div class="cert-empty">
|
||||
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
|
||||
<div>Loading live declarations and accounts from Fineract…</div>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty" style="color: #ef4444;">
|
||||
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
|
||||
<div>Could not load real data from Fineract. Check connection.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Financial Year</label>
|
||||
<select [(ngModel)]="selectedFy" (ngModelChange)="loadRealData()">
|
||||
@for (y of availableFys; track y) {
|
||||
<option [value]="y">{{ y }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Search live data..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
|
||||
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="row.row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.row.band === 'SENIOR'"
|
||||
>
|
||||
{{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ row.row.age }} yrs</td>
|
||||
<td class="small">{{ row.row.record.branch || '—' }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ row.row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.row.mode === 'QUARTERLY'">
|
||||
{{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (row.row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.row.eligibility.eligible) {
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
@if (!row.row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-table--inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of row.row.schedule.lines; track line.quarter) {
|
||||
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No active clients match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* 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, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { forkJoin, of, Observable, Subject } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { ClientsService } from '../../../../clients/clients.service';
|
||||
import { SavingsService } from '../../../../savings/savings.service';
|
||||
import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service';
|
||||
|
||||
import {
|
||||
CertificatePreviewDialogComponent,
|
||||
CertificatePreviewDialogData
|
||||
} from '../../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm15ghPdf } from '../../certificate-pdf';
|
||||
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
Form15Kind,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
isSeniorInFy,
|
||||
normaliseFy,
|
||||
projectQuarterlyCredits,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor,
|
||||
isTdsApplicable,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
fyOf
|
||||
} from '../tds-15gh.compute';
|
||||
|
||||
import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component';
|
||||
|
||||
export interface RealForm15ghRow {
|
||||
row: Form15ghRow;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-real-form15gh-view',
|
||||
templateUrl: './real-form15gh-view.component.html',
|
||||
styleUrls: ['../../certificates.shared.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
loading = true;
|
||||
loadError = false;
|
||||
rows: RealForm15ghRow[] = [];
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
bandFilter: '' | ThresholdBand = '';
|
||||
statusFilter: '' | Form15Status = '';
|
||||
ineligibleOnly = false;
|
||||
|
||||
/** Which row's quarter-wise breakdown is open. */
|
||||
expandedId: string | null = null;
|
||||
selectedFy = 'FY 2026-27';
|
||||
|
||||
// Per-client caches
|
||||
private clientDataCache = new Map<string, Observable<any>>();
|
||||
private clientKycCache = new Map<string, Observable<any>>();
|
||||
private clientAccountsCache = new Map<string, Observable<any>>();
|
||||
|
||||
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRealData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadRealData(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
this.clientDataCache.clear();
|
||||
this.clientKycCache.clear();
|
||||
this.clientAccountsCache.clear();
|
||||
|
||||
// Fetch up to 100 active individual clients.
|
||||
this.clientsService
|
||||
.searchByText('', 0, 100, '', '', true, 'INDIVIDUAL', true)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
map((page: any) => page?.content || page?.pageItems || (Array.isArray(page) ? page : [])),
|
||||
switchMap((clients: any[]) => {
|
||||
if (!clients.length) {
|
||||
return of([] as RealForm15ghRow[]);
|
||||
}
|
||||
return forkJoin(clients.map((c) => this.buildRealRow(c))).pipe(
|
||||
map((rows) => rows.filter((r): r is RealForm15ghRow => r !== null))
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as RealForm15ghRow[]);
|
||||
})
|
||||
)
|
||||
.subscribe((rows) => {
|
||||
this.rows = rows;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private buildRealRow(clientSummary: any): Observable<RealForm15ghRow | null> {
|
||||
const clientId = clientSummary?.id != null ? String(clientSummary.id) : null;
|
||||
if (!clientId) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return forkJoin({
|
||||
client: this.getClientDataCached(clientId),
|
||||
kyc: this.getClientKycCached(clientId),
|
||||
accountsSummary: this.getClientAccountsCached(clientId)
|
||||
}).pipe(
|
||||
switchMap(({ client, kyc, accountsSummary }) => {
|
||||
const activeSavings = (accountsSummary?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
|
||||
if (!activeSavings.length) {
|
||||
// If no accounts, we can't calculate interest. But we can still build an empty row if a declaration is filed
|
||||
return of({ client, kyc, accountDetailsList: [] });
|
||||
}
|
||||
|
||||
return forkJoin(activeSavings.map((a: any) => this.fetchAccountDetails(a))).pipe(
|
||||
map((detailsList) => ({
|
||||
client,
|
||||
kyc,
|
||||
accountDetailsList: (detailsList as any[]).filter(Boolean)
|
||||
}))
|
||||
);
|
||||
}),
|
||||
map(({ client, kyc, accountDetailsList }) => {
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const record = this.mapToRecord(client, kyc, accountDetailsList);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const row = this.computeRow(record, accountDetailsList);
|
||||
return {
|
||||
row,
|
||||
clientId
|
||||
};
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
private getClientDataCached(clientId: string): Observable<any> {
|
||||
if (!this.clientDataCache.has(clientId)) {
|
||||
this.clientDataCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientData(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientDataCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientKycCached(clientId: string): Observable<any> {
|
||||
if (!this.clientKycCache.has(clientId)) {
|
||||
this.clientKycCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientKyc(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientKycCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientAccountsCached(clientId: string): Observable<any> {
|
||||
if (!this.clientAccountsCache.has(clientId)) {
|
||||
this.clientAccountsCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientAccountData(clientId).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientAccountsCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private fetchAccountDetails(accountSummary: any): Observable<any> {
|
||||
const id = String(accountSummary.id);
|
||||
const code = accountSummary.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
private mapToRecord(client: any, kyc: any, detailsList: any[]): Form15ghRecord | null {
|
||||
const dob = toIso(client?.dateOfBirth) ?? '';
|
||||
if (!dob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fy = normaliseFy(this.selectedFy);
|
||||
const hasDeclaration = !!kyc?.form15Applicable && normaliseFy(kyc?.form15Fy ?? '') === fy;
|
||||
const status: Form15Status = hasDeclaration ? 'FILED' : 'PENDING';
|
||||
|
||||
// Sum estimated time deposit interest for the selected year
|
||||
let estimatedInterest = 0;
|
||||
let depositPrincipal = 0;
|
||||
|
||||
for (const d of detailsList) {
|
||||
const code = d.depositType?.code ?? '';
|
||||
const isTimeDeposit = code.includes('fixedDeposit') || code.includes('recurringDeposit');
|
||||
if (isTimeDeposit) {
|
||||
depositPrincipal += d.depositAmount ?? d.nominalAnnualInterestRate ?? 0;
|
||||
// Sum interest transactions in the selected FY
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (iso && fyOf(iso) === fy) {
|
||||
estimatedInterest += t.amount ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to a small placeholder principal/interest if the live accounts have no transaction history yet,
|
||||
// so the table is not completely blank of figures.
|
||||
if (estimatedInterest === 0 && depositPrincipal > 0) {
|
||||
estimatedInterest = Math.round(depositPrincipal * 0.07);
|
||||
}
|
||||
|
||||
return {
|
||||
customerId: client.accountNo || String(client.id),
|
||||
customerName: client.displayName || '',
|
||||
customerPan: (kyc?.panNumber ?? '').toUpperCase(),
|
||||
customerAddress: client.officeName || '',
|
||||
dob,
|
||||
branch: client.officeName || '',
|
||||
estimatedInterest,
|
||||
estimatedIncome: kyc?.estimatedIncome ?? 0,
|
||||
depositPrincipal,
|
||||
fy: this.selectedFy,
|
||||
status,
|
||||
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
|
||||
assessed: true,
|
||||
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
private computeRow(record: Form15ghRecord, detailsList: any[]): Form15ghRow {
|
||||
const ctx = {
|
||||
fy: record.fy,
|
||||
dateOfBirth: record.dob,
|
||||
pan: record.customerPan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
const declaration: Declaration | null =
|
||||
record.status === 'FILED'
|
||||
? {
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||
fy: record.fy
|
||||
}
|
||||
: null;
|
||||
|
||||
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||
|
||||
// Map the actual account transactions to InterestCredit inputs for the schedule calculation
|
||||
const accountInputs: TdsAccountInput[] = detailsList.map((d) => {
|
||||
const code = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? String(d.id),
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? Number(record.customerId),
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (iso) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ref,
|
||||
credits
|
||||
};
|
||||
});
|
||||
|
||||
const collectedCredits = collectTdsCredits(Number(record.customerId), accountInputs, record.fy);
|
||||
// If no real transactions are recorded yet, fall back to spreading the estimated interest
|
||||
const creditsToUse = collectedCredits.length
|
||||
? collectedCredits
|
||||
: projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||
|
||||
const schedule = buildSchedule(mode, creditsToUse, { ...ctx, declaration });
|
||||
const withoutDeclaration = buildSchedule(mode, creditsToUse, { ...ctx, declaration: null });
|
||||
|
||||
return {
|
||||
record,
|
||||
form: declarationFormFor(record.fy, record.dob),
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||
age: ageInFy(record.dob, record.fy),
|
||||
mode,
|
||||
schedule,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
eligibility: checkEligibility(ctx, {
|
||||
estimatedInterest: record.estimatedInterest,
|
||||
estimatedIncome: record.estimatedIncome || 250000,
|
||||
incomeOnRecord: true
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
get filteredRows(): RealForm15ghRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter(({ row }) => {
|
||||
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
const r = row.record;
|
||||
return (
|
||||
r.customerName.toLowerCase().includes(q) ||
|
||||
r.customerId.toLowerCase().includes(q) ||
|
||||
r.customerPan.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
countGeneralBand(): number {
|
||||
return this.rows.filter((r) => r.row.band === 'GENERAL').length;
|
||||
}
|
||||
|
||||
countSeniorBand(): number {
|
||||
return this.rows.filter((r) => r.row.band === 'SENIOR').length;
|
||||
}
|
||||
|
||||
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||
const fy = normaliseFy(this.selectedFy);
|
||||
const cfg = FY_TAX_CONFIG[fy];
|
||||
return {
|
||||
form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
countFiled(): number {
|
||||
return this.rows.filter((r) => r.row.record.status === 'FILED').length;
|
||||
}
|
||||
|
||||
countIneligible(): number {
|
||||
return this.rows.filter((r) => !r.row.eligibility.eligible).length;
|
||||
}
|
||||
|
||||
totalInterest(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.row.record.estimatedInterest, 0);
|
||||
}
|
||||
|
||||
totalTdsSaved(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.row.tdsSaved, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.bandFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.ineligibleOnly = false;
|
||||
}
|
||||
|
||||
toggleExpanded(row: RealForm15ghRow): void {
|
||||
this.expandedId = this.expandedId === row.row.record.customerId ? null : row.row.record.customerId;
|
||||
}
|
||||
|
||||
isExpanded(row: RealForm15ghRow): boolean {
|
||||
return this.expandedId === row.row.record.customerId;
|
||||
}
|
||||
|
||||
viewCertificate(row: RealForm15ghRow): void {
|
||||
const data: CertificatePreviewDialogData = {
|
||||
kind: 'form15gh',
|
||||
form15gh: { record: row.row.record, kind: row.row.kind, age: row.row.age }
|
||||
};
|
||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||
width: '880px',
|
||||
maxWidth: '96vw',
|
||||
maxHeight: '92vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'cert-preview-dialog',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
downloadCertificate(row: RealForm15ghRow): void {
|
||||
const r = row.row.record;
|
||||
if (!row.row.eligibility.eligible) {
|
||||
this.snack.open(`Cannot issue: ${row.row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||
return;
|
||||
}
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `${row.row.form === '121' ? 'Form 121' : `Form ${row.row.form}`} Declaration`,
|
||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm15ghPdf(r, row.row.kind, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* 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 {
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsContext,
|
||||
ageInFy,
|
||||
declarationFormFor,
|
||||
annualSchedule,
|
||||
bucketByQuarter,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
cumulativeLiability,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
lateDeductionInterest,
|
||||
monthsOrPartBetween,
|
||||
projectQuarterlyCredits,
|
||||
quarterlySchedule,
|
||||
reconcile,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from './tds-15gh.compute';
|
||||
|
||||
const FY = 'FY 2026-27';
|
||||
|
||||
/** Under 60 for FY 2026-27, valid individual PAN. */
|
||||
function general(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||
return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||
}
|
||||
|
||||
/** 60+ for FY 2026-27. */
|
||||
function senior(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||
return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||
}
|
||||
|
||||
/** Four dated credits, one per quarter. */
|
||||
function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] {
|
||||
return [
|
||||
{ date: '2026-06-30', amount: q1 },
|
||||
{ date: '2026-09-30', amount: q2 },
|
||||
{ date: '2026-12-31', amount: q3 },
|
||||
{ date: '2027-03-31', amount: q4 }
|
||||
];
|
||||
}
|
||||
|
||||
describe('194A threshold behaviour', () => {
|
||||
it('deducts nothing while the aggregate stays under the trigger', () => {
|
||||
expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('deducts nothing when the aggregate lands exactly on the trigger', () => {
|
||||
// The section bites only where the amount *exceeds* the limit.
|
||||
expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('treats the threshold as a trigger, not an allowance', () => {
|
||||
// ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above
|
||||
// the ₹50,000 line.
|
||||
const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general());
|
||||
expect(s.totalTds).toBe(5_200);
|
||||
expect(s.totalTds).not.toBe(200);
|
||||
});
|
||||
|
||||
it('applies the senior citizen trigger of ₹1,00,000', () => {
|
||||
const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000
|
||||
expect(quarterlySchedule(c, general()).totalTds).toBe(8_000);
|
||||
expect(quarterlySchedule(c, senior()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to the 206AA rate of 20% where PAN is not on record', () => {
|
||||
const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000
|
||||
expect(annualSchedule(c, general()).totalTds).toBe(10_000);
|
||||
expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => {
|
||||
it('loads the whole year-to-date liability into that quarter', () => {
|
||||
// ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3.
|
||||
const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general());
|
||||
expect(s.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
0,
|
||||
6_000,
|
||||
2_000
|
||||
]);
|
||||
expect(s.lines[2].thresholdCrossedHere).toBe(true);
|
||||
// Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is
|
||||
// correct: the earlier quarters became liable the moment the line was
|
||||
// crossed, and the branch collects the arrears now.
|
||||
expect(s.totalTds).toBe(8_000);
|
||||
});
|
||||
|
||||
it('never deducts a negative amount on a rising book', () => {
|
||||
const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general());
|
||||
for (const line of s.lines) {
|
||||
expect(line.tds).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quarterly and annual settlement agree to the rupee', () => {
|
||||
const distributions: [number, number, number, number][] = [
|
||||
[
|
||||
12_000,
|
||||
12_000,
|
||||
12_000,
|
||||
12_000
|
||||
],
|
||||
[
|
||||
20_000,
|
||||
20_000,
|
||||
20_000,
|
||||
20_000
|
||||
],
|
||||
[
|
||||
60_000,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
75_000
|
||||
],
|
||||
[
|
||||
49_999,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
13_333,
|
||||
13_333,
|
||||
13_333,
|
||||
13_334
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
99_999
|
||||
],
|
||||
[
|
||||
250_000,
|
||||
250_000,
|
||||
250_000,
|
||||
250_000
|
||||
]
|
||||
];
|
||||
|
||||
it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => {
|
||||
const c = credits(q1, q2, q3, q4);
|
||||
for (const ctx of [
|
||||
general(),
|
||||
senior(),
|
||||
general({ pan: '' })
|
||||
]) {
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
expect(q.totalTds).toBe(a.totalTds);
|
||||
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds under section 288B rounding to the nearest ₹10', () => {
|
||||
const ctx = general({ config: { roundTo: 10 } });
|
||||
const c = credits(13_333, 13_333, 13_333, 13_334);
|
||||
expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds);
|
||||
expect(annualSchedule(c, ctx).totalTds % 10).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property test. Rounding drift only shows up on awkward numbers, so sweep a
|
||||
* deterministic spread of them rather than trusting hand-picked cases.
|
||||
*/
|
||||
it('holds across 2000 pseudo-random interest distributions', () => {
|
||||
let seed = 20260801;
|
||||
const next = (max: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 4294967296) * max);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||
const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } });
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
if (q.totalTds !== a.totalTds) {
|
||||
throw new Error(
|
||||
`Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||
);
|
||||
}
|
||||
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds across 2000 distributions with a declaration at every acceptance point', () => {
|
||||
let seed = 19980731;
|
||||
const next = (max: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 4294967296) * max);
|
||||
};
|
||||
const dates = [
|
||||
'2026-04-01',
|
||||
'2026-05-15',
|
||||
'2026-08-01',
|
||||
'2026-10-01',
|
||||
'2027-01-10',
|
||||
'2027-03-31'
|
||||
];
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||
const ctx = general({
|
||||
declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' },
|
||||
config: { roundTo: i % 2 === 0 ? 1 : 10 }
|
||||
});
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
if (q.totalTds !== a.totalTds) {
|
||||
throw new Error(
|
||||
`Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` +
|
||||
`quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('adjusts within the year when interest is reversed', () => {
|
||||
// Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal
|
||||
// brings the year back to ₹50,000, which does not exceed the trigger at all,
|
||||
// so the whole ₹8,000 is given back in Q3.
|
||||
const c = credits(40_000, 40_000, -30_000, 0);
|
||||
const q = quarterlySchedule(c, general());
|
||||
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
8_000,
|
||||
-8_000,
|
||||
0
|
||||
]);
|
||||
expect(q.totalTds).toBe(0);
|
||||
expect(annualSchedule(c, general()).totalTds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Form 121 declaration', () => {
|
||||
const fromApril = general({
|
||||
declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' }
|
||||
});
|
||||
const fromOctober = general({
|
||||
declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' }
|
||||
});
|
||||
|
||||
it('stops deduction for the whole year when on record from April', () => {
|
||||
const c = credits(30_000, 30_000, 30_000, 30_000);
|
||||
expect(quarterlySchedule(c, fromApril).totalTds).toBe(0);
|
||||
expect(annualSchedule(c, fromApril).totalTds).toBe(0);
|
||||
expect(reconcile(c, fromApril).matched).toBe(true);
|
||||
});
|
||||
|
||||
it('shields only the interest credited on or after it was accepted', () => {
|
||||
const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(s.liableInterest).toBe(60_000);
|
||||
expect(s.shieldedInterest).toBe(60_000);
|
||||
});
|
||||
|
||||
it('does not claw back tax already remitted before it arrived', () => {
|
||||
const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
6_000,
|
||||
0,
|
||||
0
|
||||
]);
|
||||
expect(q.totalTds).toBe(6_000);
|
||||
expect(q.notes[0]).toContain('cannot reverse it');
|
||||
});
|
||||
|
||||
it('leaves the two modes in agreement even when taken mid-year', () => {
|
||||
// A declaration is prospective, so the year-end figure still sees the
|
||||
// pre-declaration interest as liable.
|
||||
const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(r.quarterlyTotal).toBe(6_000);
|
||||
expect(r.annualTotal).toBe(6_000);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the divergence when liability falls after deductions were frozen', () => {
|
||||
// Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in
|
||||
// Q3, after the declaration was on record, drags the year back under
|
||||
// ₹50,000 — the liability is now nil but the branch cannot refund.
|
||||
const c: InterestCredit[] = [
|
||||
{ date: '2026-06-30', amount: 60_000 },
|
||||
{ date: '2026-12-31', amount: -15_000 }
|
||||
];
|
||||
const r = reconcile(c, fromOctober);
|
||||
expect(r.quarterlyTotal).toBe(6_000);
|
||||
expect(r.annualTotal).toBe(0);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('age is measured against the financial year, not today', () => {
|
||||
it('gives the senior threshold to a customer who turns 60 in March', () => {
|
||||
expect(ageInFy('1967-03-20', FY)).toBe(60);
|
||||
expect(formKindFor('1967-03-20', FY)).toBe('15H');
|
||||
const ctx = general({ dateOfBirth: '1967-03-20' });
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000);
|
||||
});
|
||||
|
||||
it('keeps the general threshold for a customer who turns 60 the next April', () => {
|
||||
expect(ageInFy('1967-04-20', FY)).toBe(59);
|
||||
expect(formKindFor('1967-04-20', FY)).toBe('15G');
|
||||
});
|
||||
});
|
||||
|
||||
describe('savings interest is outside section 194A', () => {
|
||||
it.each([
|
||||
[
|
||||
'depositAccountType.fixedDeposit',
|
||||
true
|
||||
],
|
||||
[
|
||||
'depositAccountType.recurringDeposit',
|
||||
true
|
||||
],
|
||||
[
|
||||
'depositAccountType.savingsDeposit',
|
||||
false
|
||||
],
|
||||
[
|
||||
'depositAccountType.currentDeposit',
|
||||
false
|
||||
],
|
||||
[
|
||||
'',
|
||||
false
|
||||
]
|
||||
])('%s -> %s', (code, expected) => {
|
||||
expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps savings interest out of the aggregate entirely', () => {
|
||||
const accounts: TdsAccountInput[] = [
|
||||
{
|
||||
accountNo: 'FD001',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: credits(10_000, 10_000, 10_000, 10_000)
|
||||
},
|
||||
{
|
||||
accountNo: 'SB001',
|
||||
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: credits(5_000, 5_000, 5_000, 5_000)
|
||||
}
|
||||
];
|
||||
const collected = collectTdsCredits(1, accounts, FY);
|
||||
|
||||
// ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and
|
||||
// nothing is deducted. Folding the savings interest in would have crossed it.
|
||||
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000);
|
||||
expect(quarterlySchedule(collected, general()).totalTds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose PAN the tax is deducted against', () => {
|
||||
it('uses the account holder for a single-name account', () => {
|
||||
expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7);
|
||||
expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7);
|
||||
});
|
||||
|
||||
it('uses the first-named holder on a joint account', () => {
|
||||
expect(
|
||||
tdsDeducteeOf({
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }]
|
||||
})
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('uses the guardian on a minor account', () => {
|
||||
expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4);
|
||||
});
|
||||
|
||||
it('does not count a joint deposit twice across the two holders', () => {
|
||||
const jointFd: TdsAccountInput = {
|
||||
accountNo: 'FD-JOINT',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }],
|
||||
credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000
|
||||
};
|
||||
|
||||
// Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax.
|
||||
const primary = collectTdsCredits(7, [jointFd], FY);
|
||||
expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||
expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000);
|
||||
|
||||
// The second holder carries none of it — no double deduction on one deposit.
|
||||
const secondHolder = collectTdsCredits(9, [jointFd], FY);
|
||||
expect(secondHolder).toEqual([]);
|
||||
expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores any share percentage when attributing interest', () => {
|
||||
// A 50/50 joint account still reports the full amount against the primary
|
||||
// holder; splitting would produce two deductees for one deposit.
|
||||
const jointFd: TdsAccountInput = {
|
||||
accountNo: 'FD-JOINT',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }],
|
||||
credits: credits(15_000, 15_000, 15_000, 15_000)
|
||||
};
|
||||
expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||
});
|
||||
|
||||
it('aggregates a customer across every branch', () => {
|
||||
// Three deposits in three branches, each under the trigger on its own.
|
||||
const accounts: TdsAccountInput[] = [
|
||||
'FD-MUM',
|
||||
'FD-PUN',
|
||||
'FD-NAS'
|
||||
].map((accountNo, i) => ({
|
||||
accountNo,
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: [{ date: `2026-0${6 + i}-30`, amount: [
|
||||
20_000,
|
||||
18_000,
|
||||
15_000
|
||||
][i] }]
|
||||
}));
|
||||
|
||||
const collected = collectTdsCredits(1, accounts, FY);
|
||||
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000);
|
||||
// Branch by branch none would have crossed ₹50,000. Bank-wide, it does.
|
||||
expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300);
|
||||
});
|
||||
|
||||
it('keeps other financial years out of the selected year', () => {
|
||||
const account: TdsAccountInput = {
|
||||
accountNo: 'FD001',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: [
|
||||
{ date: '2026-03-31', amount: 99_000 }, // FY 2025-26
|
||||
{ date: '2026-06-30', amount: 10_000 }, // FY 2026-27
|
||||
{ date: '2027-04-01', amount: 99_000 } // FY 2027-28
|
||||
]
|
||||
};
|
||||
expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settlement mode follows the product, not the customer', () => {
|
||||
it.each([
|
||||
[
|
||||
'savingsPostingInterestPeriodType.monthly',
|
||||
'QUARTERLY'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.quarterly',
|
||||
'QUARTERLY'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.annual',
|
||||
'ANNUAL'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.annually',
|
||||
'ANNUAL'
|
||||
]
|
||||
])('%s -> %s', (code, expected) => {
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not mistake a half-yearly product for an annual one', () => {
|
||||
// 'biannual' contains 'annual' — a naive substring test settles half-yearly
|
||||
// products once a year, which is a deduction default.
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe(
|
||||
'QUARTERLY'
|
||||
);
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY');
|
||||
});
|
||||
|
||||
it('reads the value when no code is present, and defaults safely', () => {
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL');
|
||||
// Unknown or missing configuration settles on the quarterly cycle, which is
|
||||
// the conservative choice — deducting sooner is never a default.
|
||||
expect(tdsModeFor({})).toBe('QUARTERLY');
|
||||
expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAN validation', () => {
|
||||
it.each([
|
||||
[
|
||||
'ABCPM1234E',
|
||||
true
|
||||
],
|
||||
[
|
||||
'EFGHV5678A',
|
||||
true
|
||||
],
|
||||
[
|
||||
'PQRSR2233Z',
|
||||
false
|
||||
],
|
||||
[
|
||||
'TUVWS3344K',
|
||||
false
|
||||
],
|
||||
[
|
||||
'ABCP1234E',
|
||||
false
|
||||
],
|
||||
[
|
||||
'',
|
||||
false
|
||||
]
|
||||
])('%s -> %s', (pan, expected) => {
|
||||
expect(isWellFormedPan(pan)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const income = (estimatedInterest: number, estimatedIncome: number) => ({
|
||||
estimatedInterest,
|
||||
estimatedIncome,
|
||||
incomeOnRecord: true
|
||||
});
|
||||
|
||||
/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */
|
||||
const OLD_FY = 'FY 2025-26';
|
||||
|
||||
describe('which declaration form is in force', () => {
|
||||
it('splits by age up to FY 2025-26', () => {
|
||||
expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G');
|
||||
expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H');
|
||||
});
|
||||
|
||||
it('is a single Form 121 from FY 2026-27, whatever the age', () => {
|
||||
expect(declarationFormFor(FY, '1985-02-14')).toBe('121');
|
||||
expect(declarationFormFor(FY, '1958-06-22')).toBe('121');
|
||||
});
|
||||
|
||||
it('keeps the senior threshold even though the forms merged', () => {
|
||||
// The two forms became one, but the deduction trigger is still age-based.
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000);
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => {
|
||||
const old = (o: Partial<TdsContext> = {}) => general({ fy: OLD_FY, ...o });
|
||||
const oldSenior = (o: Partial<TdsContext> = {}) => senior({ fy: OLD_FY, ...o });
|
||||
|
||||
it('accepts a 15G within both the income and the interest ceiling', () => {
|
||||
const r = checkEligibility(old(), income(90_000, 320_000));
|
||||
expect(r.form).toBe('15G');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a 15G whose estimated income exceeds the basic exemption', () => {
|
||||
const r = checkEligibility(old(), income(38_500, 850_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('exceeds the basic exemption');
|
||||
});
|
||||
|
||||
it('rejects a 15G whose interest alone exceeds the basic exemption', () => {
|
||||
// The second limb: interest had to clear the limit as well as income.
|
||||
expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('places no interest ceiling on a 15H', () => {
|
||||
// A senior citizen with ₹8,00,000 of interest could still declare, provided
|
||||
// deductions and rebate left the liability at nil.
|
||||
const r = checkEligibility(oldSenior(), income(800_000, 900_000));
|
||||
expect(r.form).toBe('15H');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a 15H that leaves a liability', () => {
|
||||
const r = checkEligibility(oldSenior(), income(800_000, 1_500_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('nil');
|
||||
});
|
||||
|
||||
it('cites the rule that applied to that year', () => {
|
||||
const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('Rule 29C');
|
||||
});
|
||||
|
||||
it('still checks the interest limb when income is not on record', () => {
|
||||
// Income has to be declared by the payee, but the bank knows the interest
|
||||
// figure, so that limb remains assessable.
|
||||
expect(
|
||||
checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibility from FY 2026-27 — the single Form 121 test', () => {
|
||||
it('drops the interest limb entirely', () => {
|
||||
// Under a 15G this failed: interest of ₹4,50,000 was over the basic
|
||||
// exemption limit. Form 121 asks only whether the tax liability is nil, so
|
||||
// the same payee is now eligible.
|
||||
expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false);
|
||||
const r = checkEligibility(general(), income(450_000, 380_000));
|
||||
expect(r.form).toBe('121');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('applies one nil-liability ceiling to every age', () => {
|
||||
for (const ctx of [
|
||||
general(),
|
||||
senior()
|
||||
]) {
|
||||
expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true);
|
||||
const over = checkEligibility(ctx, income(800_000, 1_500_000));
|
||||
expect(over.eligible).toBe(false);
|
||||
expect(over.reasons.join(' ')).toContain('nil');
|
||||
}
|
||||
});
|
||||
|
||||
it('takes PAN only — Aadhaar is not a substitute', () => {
|
||||
const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('Rule 211');
|
||||
expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute');
|
||||
});
|
||||
|
||||
it('refuses an entity PAN', () => {
|
||||
const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('entity');
|
||||
});
|
||||
|
||||
it('refuses a non-resident', () => {
|
||||
expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the nil-liability test as unassessed rather than passing it silently', () => {
|
||||
// The branch cannot compute income earned outside the bank, so a missing
|
||||
// figure must not read as a pass.
|
||||
const r = checkEligibility(general(), {
|
||||
estimatedInterest: 90_000,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
expect(r.unassessed.length).toBe(1);
|
||||
expect(r.unassessed[0]).toContain('not on record');
|
||||
// With no interest limb left, nothing else can fail it — so the branch must
|
||||
// read the unassessed note rather than treat the row as cleared.
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statutory references travel with the year', () => {
|
||||
it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => {
|
||||
expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961');
|
||||
expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A');
|
||||
expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q');
|
||||
expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY');
|
||||
|
||||
expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025');
|
||||
expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393');
|
||||
expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140');
|
||||
expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY');
|
||||
});
|
||||
|
||||
it('carries the same thresholds and rates across the changeover', () => {
|
||||
const a = FY_TAX_CONFIG['2025-26'];
|
||||
const b = FY_TAX_CONFIG['2026-27'];
|
||||
expect(b.thresholdGeneral).toBe(a.thresholdGeneral);
|
||||
expect(b.thresholdSenior).toBe(a.thresholdSenior);
|
||||
expect(b.rate).toBe(a.rate);
|
||||
expect(b.rateNoPan).toBe(a.rateNoPan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default interest under section 201(1A)', () => {
|
||||
it('counts any part of a month as a full month', () => {
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6);
|
||||
});
|
||||
|
||||
it('charges 1% a month for deducting late and 1.5% for depositing late', () => {
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({
|
||||
months: 6,
|
||||
rate: 0.01,
|
||||
interest: 360
|
||||
});
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540);
|
||||
});
|
||||
|
||||
it('charges nothing when settled on time', () => {
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('resolves the financial year of a date', () => {
|
||||
expect(fyOf('2026-04-01')).toBe('2026-27');
|
||||
expect(fyOf('2027-03-31')).toBe('2026-27');
|
||||
expect(fyOf('2026-03-31')).toBe('2025-26');
|
||||
expect(fyOf('2027-04-01')).toBe('2027-28');
|
||||
});
|
||||
|
||||
it('buckets dated credits into the right quarters and drops out-of-year dates', () => {
|
||||
const b = bucketByQuarter(
|
||||
[
|
||||
{ date: '2026-04-05', amount: 100 },
|
||||
{ date: '2026-08-01', amount: 200 },
|
||||
{ date: '2026-11-30', amount: 300 },
|
||||
{ date: '2027-01-15', amount: 400 },
|
||||
{ date: '2026-03-31', amount: 999 },
|
||||
{ date: '2027-04-01', amount: 999 }
|
||||
],
|
||||
FY
|
||||
);
|
||||
expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 });
|
||||
});
|
||||
|
||||
it('spreads an annual estimate without losing rupees to division', () => {
|
||||
for (const annual of [
|
||||
38_500,
|
||||
52_801,
|
||||
1,
|
||||
999_999
|
||||
]) {
|
||||
expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the cumulative liability directly for ad-hoc checks', () => {
|
||||
expect(cumulativeLiability(50_000, 50_000, general())).toBe(0);
|
||||
expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,878 @@
|
||||
<!--
|
||||
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="pg">
|
||||
@if (application; as a) {
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ a.branchName | uppercase }}</div>
|
||||
<h1 class="pg-title">
|
||||
{{ a.applicationNo }} — {{ a.applicantName }}
|
||||
<span [class]="badgeClass(a)">{{ statusLabels[a.overallStatus] }}</span>
|
||||
</h1>
|
||||
<p class="pg-sub npa-report-note">
|
||||
{{ a.productName }} · ₹{{ a.requestedAmount | number: '1.0-0' }} · Applied {{ a.applicationDate }} ·
|
||||
{{ progress(a) }}% through the sanctioning workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<dl class="loan-kv-grid">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Branch</dt>
|
||||
<dd>{{ a.branchName }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Channel</dt>
|
||||
<dd>
|
||||
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Customer Type</dt>
|
||||
<dd>{{ a.customerType === 'EXISTING' ? 'Existing Customer' : 'New Customer' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Tenure / Rate</dt>
|
||||
<dd>{{ a.tenureMonths }} months @ {{ a.interestRate }}%</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Monthly Income / Eligible Amount</dt>
|
||||
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }} / ₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Sanctioning Authority</dt>
|
||||
<dd>{{ sanctioningAuthorityOf(a) }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Share Member</dt>
|
||||
<dd>
|
||||
@if (a.isShareMember) {
|
||||
Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }})
|
||||
} @else {
|
||||
<span class="loan-badge loan-badge--hold">Not a Member</span>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="allotMembership(a)">Allot Shares</button>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@if (a.coBorrowerRequired) {
|
||||
<div class="loan-kv-field">
|
||||
<dt>Co-Borrower</dt>
|
||||
<dd>{{ a.coBorrowerName }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (a.guarantorRequired) {
|
||||
<div class="loan-kv-field">
|
||||
<dt>Guarantor</dt>
|
||||
<dd>{{ a.guarantorName }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Sanctioning Workflow Progress</div>
|
||||
<div class="loan-stepper">
|
||||
@for (s of stageOrder; track s) {
|
||||
<div [class]="stepStatusClass(a, s)">
|
||||
<span class="loan-step__connector"></span>
|
||||
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
|
||||
<span class="loan-step__label">{{ stageLabels[s] }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (a.overallStatus === 'REJECTED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Application Rejected — at {{ stageLabels[a.currentStage] }}</div>
|
||||
<p class="pg-sub npa-report-note">See the approval summary below for the recorded reason.</p>
|
||||
</div>
|
||||
} @else if (a.overallStatus === 'ON_HOLD') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Personal Discussion — {{ discrepancyLabels[a.discrepancyType] }}</div>
|
||||
<div class="loan-action-card">
|
||||
<p>{{ a.personalDiscussionNotes }}</p>
|
||||
<label class="inv-form-field">
|
||||
<span>Resolution Notes</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="How was this resolved?" />
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--success" (click)="resolveDiscrepancy(a)">
|
||||
Resolve & Proceed to Loan Committee
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (a.overallStatus === 'DISBURSED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Disbursed</div>
|
||||
<dl class="loan-kv-grid">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Disbursement Date</dt>
|
||||
<dd>{{ a.disbursementDate }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Mode</dt>
|
||||
<dd>{{ a.disbursementMode }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>ACH Confirmation</dt>
|
||||
<dd>{{ a.achConfirmationRef }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>EMI Activation Confirmation</dt>
|
||||
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">EMI Repayment Schedule</div>
|
||||
@if (isRevolving(a)) {
|
||||
<p class="pg-sub npa-report-note">
|
||||
{{ a.productName }} is a revolving/renewable limit — repaid on demand and renewed annually, not on a fixed
|
||||
EMI schedule.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="inv-kpis npa-kpis--ratio">
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Loan Amount</span>
|
||||
<span class="inv-kpi__value">₹{{ a.requestedAmount | number: '1.0-0' }}</span>
|
||||
<span class="inv-kpi__sub">{{ a.tenureMonths }} months @ {{ a.interestRate }}%</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Monthly EMI</span>
|
||||
<span class="inv-kpi__value">₹{{ emiAmountFor(a) | number: '1.0-0' }}</span>
|
||||
<span class="inv-kpi__sub">Reducing balance</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Interest Payable</span>
|
||||
<span class="inv-kpi__value">₹{{ totalInterestFor(a) | number: '1.0-0' }}</span>
|
||||
<span class="inv-kpi__sub">over the full tenure</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Amount Payable</span>
|
||||
<span class="inv-kpi__value">₹{{ totalPayableFor(a) | number: '1.0-0' }}</span>
|
||||
<span class="inv-kpi__sub">principal + interest</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg-table-scroll loan-emi-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">Inst. No</th>
|
||||
<th>Due Date</th>
|
||||
<th class="num">Opening Balance</th>
|
||||
<th class="num">EMI</th>
|
||||
<th class="num">Principal</th>
|
||||
<th class="num col-out">Interest</th>
|
||||
<th class="num">Closing Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of emiScheduleFor(a); track row.installmentNo) {
|
||||
<tr>
|
||||
<td class="num">{{ row.installmentNo }}</td>
|
||||
<td>{{ row.dueDate }}</td>
|
||||
<td class="num">₹{{ row.openingBalance | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ row.emiAmount | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ row.principalComponent | number: '1.0-0' }}</td>
|
||||
<td class="num col-out">₹{{ row.interestComponent | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ row.closingBalance | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ===================== APPLICATION ===================== -->
|
||||
@if (a.currentStage === 'APPLICATION') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Application Received</div>
|
||||
<div class="loan-action-card">
|
||||
<p>
|
||||
Received via
|
||||
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
|
||||
on {{ a.applicationDate }}.
|
||||
</p>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="proceed(a, 'Application logged and forwarded for KYC.')"
|
||||
>
|
||||
Proceed to KYC & Document Collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== KYC & DOCUMENTS ===================== -->
|
||||
@if (a.currentStage === 'KYC_DOCUMENTS') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">KYC & Document Collection — OVD (RBI KYC Master Direction)</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.aadhaarVerified" /> Aadhaar Verified (OVD)
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.panVerified" /> PAN Verified
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.incomeProofSubmitted" /> Income Proof Submitted
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.bankStatementSubmitted" /> Bank Statement Submitted
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
|
||||
</label>
|
||||
}
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.electricityBillSubmitted" /> Electricity Bill (Address Proof)
|
||||
Submitted
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
|
||||
(e-KYC)
|
||||
</label>
|
||||
</div>
|
||||
@if (a.monthlyIncome === 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Monthly Income (₹)</span>
|
||||
<input type="number" [(ngModel)]="monthlyIncomeInput" placeholder="e.g. 45000" />
|
||||
</label>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }}
|
||||
</p>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="setIncomeNow(a)">
|
||||
Assess Income & Calculate Eligible Amount
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<dl class="loan-kv-grid" style="padding: 0 0 14px">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Monthly Income</dt>
|
||||
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Eligible Loan Amount</dt>
|
||||
<dd>₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
}
|
||||
@if (a.coBorrowerRequired) {
|
||||
<p class="pg-sub npa-report-note">Co-Borrower Required — {{ a.coBorrowerName }}</p>
|
||||
}
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!allKycDone(a)"
|
||||
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
|
||||
>
|
||||
Proceed to CIC / Credit Bureau Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== CIC / CREDIT BUREAU CHECK ===================== -->
|
||||
@if (a.currentStage === 'CIC_CHECK') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
|
||||
<div class="loan-action-card">
|
||||
@if (a.cibilScore === 0) {
|
||||
<label class="inv-form-field">
|
||||
<span>Bureau</span>
|
||||
<select [(ngModel)]="a.cicBureau">
|
||||
@for (bureau of cicBureaus; track bureau) {
|
||||
<option [value]="bureau">{{ bureau }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="checkCreditScoreNow(a)">
|
||||
Check Credit Score
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<dl class="loan-kv-grid" style="padding: 0 0 14px">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Bureau</dt>
|
||||
<dd>{{ a.cicBureau }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Credit Score</dt>
|
||||
<dd>{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Report</dt>
|
||||
<dd>{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Field Inquiry</dt>
|
||||
<dd>
|
||||
{{
|
||||
a.fiRequestPushed
|
||||
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
|
||||
: 'Not pushed yet'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
}
|
||||
@if (a.cibilScore > 0) {
|
||||
@if (!a.cicReportUploaded) {
|
||||
<div class="loan-action-row">
|
||||
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
|
||||
Upload {{ a.cicBureau }} Report
|
||||
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
|
||||
</label>
|
||||
</div>
|
||||
} @else if (!a.fiRequestPushed) {
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="pushFieldInquiryNow(a)">
|
||||
Push Field Inquiry to Agency
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="
|
||||
proceed(
|
||||
a,
|
||||
a.cicBureau +
|
||||
' report reviewed — score ' +
|
||||
a.cibilScore +
|
||||
' (' +
|
||||
cibilBandOf(a.cibilScore) +
|
||||
').'
|
||||
)
|
||||
"
|
||||
>
|
||||
Proceed to Field Investigation
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== FIELD INVESTIGATION ===================== -->
|
||||
@if (a.currentStage === 'FIELD_INVESTIGATION') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Field Investigation (FI) — Agent Report</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Address Verification</span>
|
||||
<select [(ngModel)]="a.addressVerification.status">
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="VERIFIED">Verified</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Office / Employment Verification</span>
|
||||
<select [(ngModel)]="a.officeVerification.status">
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="VERIFIED">Verified</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="inv-form-field">
|
||||
<span>Property Verification / Valuation</span>
|
||||
<select [(ngModel)]="a.propertyVerification.status">
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="VERIFIED">Verified</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Property Valuation (₹)</span>
|
||||
<input type="number" [(ngModel)]="a.propertyValuation" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="loan-kv-grid" style="padding: 0 0 14px">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Agent Report</dt>
|
||||
<dd>{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
@if (!a.agentReportUploaded) {
|
||||
<div class="loan-action-row">
|
||||
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
|
||||
Upload Agent Report
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
(change)="onAgentReportFileSelected($event, a)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!fiComplete(a) || !a.agentReportUploaded"
|
||||
(click)="completeFi(a)"
|
||||
>
|
||||
Record FI & Proceed to Personal Discussion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== PERSONAL DISCUSSION ===================== -->
|
||||
@if (a.currentStage === 'PERSONAL_DISCUSSION') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Personal Discussion with Manager</div>
|
||||
<div class="loan-action-card">
|
||||
<label class="inv-form-field">
|
||||
<span>Discrepancy (if any)</span>
|
||||
<select [(ngModel)]="discrepancySelection">
|
||||
<option value="NONE">No Discrepancy</option>
|
||||
<option value="DOCUMENT_DISCREPANCY">Document Discrepancy</option>
|
||||
<option value="NAME_MISMATCH">Name Mismatch</option>
|
||||
<option value="OFFICE_VERIFICATION_FAILED">Office Verification Failed</option>
|
||||
<option value="PROPERTY_DISCREPANCY">Property Discrepancy</option>
|
||||
<option value="FI_REPORT_DISCREPANCY">FI Report Discrepancy</option>
|
||||
<option value="FAKE_DECLARATION">Fake Declaration Suspected</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Discussion Notes</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="Notes from discussion with manager" />
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
@if (discrepancySelection === 'NONE') {
|
||||
<button type="button" class="pg-btn pg-btn--success" (click)="clearPersonalDiscussion(a)">
|
||||
Clear, Take Declaration & Proceed to Loan Committee
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="pg-btn pg-btn--warn" (click)="flagPersonalDiscussion(a)">
|
||||
Flag Discrepancy — Hold Application
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== LOAN COMMITTEE APPROVAL ===================== -->
|
||||
@if (a.currentStage === 'CREDIT_COMMITTEE') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
|
||||
<div class="loan-action-card">
|
||||
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.cicReportUploaded" disabled /> CIBIL / Credit Bureau Report
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.backgroundCheckDone" /> Background Check
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.cersaiSearchDone" /> CSE — CERSAI Search Enquiry
|
||||
</label>
|
||||
}
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.aadhaarVerified && a.panVerified" disabled /> OVD
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.managerMeetingDone" /> Manager Meeting
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.isShareMember" disabled /> Share Member Approval
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.propertyValuation > 0" disabled /> Property Valuation
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<label class="inv-form-field">
|
||||
<span>Committee Remarks</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="!committeeReportsReady(a)"
|
||||
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
(click)="rejectStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== RISK APPROVAL ===================== -->
|
||||
@if (a.currentStage === 'RISK_APPROVAL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Risk Approval — Verification Checklist</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.addressVerification.status === 'VERIFIED'" disabled /> ID / Address
|
||||
Verified
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof Verified
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.previousLoanDeclarationDone" /> Previous Loan Declaration
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.propertySearchReportDone" /> Property Search Report
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.valuationReportConfirmed" /> Valuation Report
|
||||
</label>
|
||||
}
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [ngModel]="a.backgroundCheckDone" disabled /> Personal Background Check
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.guarantorRequired" /> Guarantor Required
|
||||
</label>
|
||||
</div>
|
||||
@if (a.guarantorRequired) {
|
||||
<label class="inv-form-field">
|
||||
<span>Guarantor Name</span>
|
||||
<input type="text" [(ngModel)]="a.guarantorName" />
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Risk Remarks</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="actionRemarks"
|
||||
placeholder="ID/address, income adequacy, previous-loan declaration"
|
||||
/>
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="!riskChecklistReady(a)"
|
||||
(click)="approveStage(a, 'riskApproval', 'Risk Approval')"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
(click)="rejectStage(a, 'riskApproval', 'Risk Approval')"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== LEGAL APPROVAL ===================== -->
|
||||
@if (a.currentStage === 'LEGAL_APPROVAL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Legal Approval — Fees & A/c Opened By</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.feesAndAccountOpened" /> Fees & A/c Opening Done
|
||||
</label>
|
||||
@if (a.secured) {
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.mortgageCreated" /> Mortgage Created
|
||||
</label>
|
||||
}
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.legalStampDone" /> Legal Stamp Done
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
|
||||
</label>
|
||||
</div>
|
||||
<label class="inv-form-field">
|
||||
<span>Agreement Signing Slot</span>
|
||||
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Legal Remarks</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="!legalChecklistDone(a)"
|
||||
(click)="approveStage(a, 'legalApproval', 'Legal Approval')"
|
||||
>
|
||||
Approve & Allot Signing Slot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
(click)="rejectStage(a, 'legalApproval', 'Legal Approval')"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== CUSTOMER SIGNING SLOT ===================== -->
|
||||
@if (a.currentStage === 'CUSTOMER_SIGNING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Customer Signing Slot — {{ a.agreementSigningSlot }}</div>
|
||||
<div class="loan-action-card">
|
||||
<p>Loan Agreement and Sanction Letter ready for the customer's acknowledgement and signature.</p>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="markSigned(a)">
|
||||
Mark Signed by Customer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== CHEQUE RECEIVED / ACH SIGN ===================== -->
|
||||
@if (a.currentStage === 'CHEQUE_ACH') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Cheque Received / ACH Sign</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.chequeReceived" /> Cheque Received
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.achSigned" /> ACH Mandate Signed
|
||||
</label>
|
||||
</div>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!a.chequeReceived || !a.achSigned"
|
||||
(click)="confirmChequeAndProceed(a)"
|
||||
>
|
||||
Confirm & Proceed to Post-Disbursement Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== POST-DISBURSEMENT CHECK ===================== -->
|
||||
@if (a.currentStage === 'POST_CHECK') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Post-Disbursement Check — Final Compliance Gate</div>
|
||||
<div class="loan-action-card">
|
||||
<p class="pg-sub npa-report-note">Document Signing by:</p>
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item"> <input type="checkbox" [(ngModel)]="a.signedByAgent" /> Agent </label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.signedByManager" /> Manager
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.signedByCommittee" /> Committee
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.signedByRiskApproval" /> Risk Approval
|
||||
</label>
|
||||
<label class="loan-checklist-item">
|
||||
<input type="checkbox" [(ngModel)]="a.signedByLegalApproval" /> Legal Approval
|
||||
</label>
|
||||
</div>
|
||||
<label class="inv-form-field">
|
||||
<span>Remarks</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||
</label>
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="!postCheckSignaturesDone(a)"
|
||||
(click)="approveStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
|
||||
>
|
||||
Clear for Disbursement
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--warn" (click)="exceptionApprove(a)">
|
||||
Approve on Exception
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
(click)="rejectStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== DISBURSEMENT ===================== -->
|
||||
@if (a.currentStage === 'DISBURSED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Ready for Disbursement</div>
|
||||
<div class="loan-action-card">
|
||||
<label class="inv-form-field">
|
||||
<span>Disbursement Mode</span>
|
||||
<select [(ngModel)]="disbursementMode">
|
||||
<option value="Account Credit">Account Credit</option>
|
||||
<option value="NEFT">NEFT</option>
|
||||
<option value="RTGS">RTGS</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Disbursing also confirms EMI Activation for this account in the same step.
|
||||
</p>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--success" (click)="disburseNow(a)">
|
||||
Disburse Now & Confirm EMI Activation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Approval Summary</div>
|
||||
<div class="loan-approval-summary">
|
||||
<div
|
||||
class="loan-approval-tile"
|
||||
[class.loan-approval-tile--approved]="
|
||||
a.creditCommitteeApproval.decision === 'APPROVED' ||
|
||||
a.creditCommitteeApproval.decision === 'EXCEPTION_APPROVED'
|
||||
"
|
||||
[class.loan-approval-tile--rejected]="a.creditCommitteeApproval.decision === 'REJECTED'"
|
||||
[class.loan-approval-tile--pending]="a.creditCommitteeApproval.decision === 'PENDING'"
|
||||
>
|
||||
<span class="loan-approval-tile__role">Loan Committee</span>
|
||||
<span class="loan-approval-tile__decision">{{ a.creditCommitteeApproval.decision }}</span>
|
||||
<span>{{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }}</span>
|
||||
<span>{{ a.creditCommitteeApproval.remarks }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="loan-approval-tile"
|
||||
[class.loan-approval-tile--approved]="
|
||||
a.riskApproval.decision === 'APPROVED' || a.riskApproval.decision === 'EXCEPTION_APPROVED'
|
||||
"
|
||||
[class.loan-approval-tile--rejected]="a.riskApproval.decision === 'REJECTED'"
|
||||
[class.loan-approval-tile--pending]="a.riskApproval.decision === 'PENDING'"
|
||||
>
|
||||
<span class="loan-approval-tile__role">Risk Approval</span>
|
||||
<span class="loan-approval-tile__decision">{{ a.riskApproval.decision }}</span>
|
||||
<span>{{ a.riskApproval.approverName }} · {{ a.riskApproval.date }}</span>
|
||||
<span>{{ a.riskApproval.remarks }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="loan-approval-tile"
|
||||
[class.loan-approval-tile--approved]="
|
||||
a.legalApproval.decision === 'APPROVED' || a.legalApproval.decision === 'EXCEPTION_APPROVED'
|
||||
"
|
||||
[class.loan-approval-tile--rejected]="a.legalApproval.decision === 'REJECTED'"
|
||||
[class.loan-approval-tile--pending]="a.legalApproval.decision === 'PENDING'"
|
||||
>
|
||||
<span class="loan-approval-tile__role">Legal Approval</span>
|
||||
<span class="loan-approval-tile__decision">{{ a.legalApproval.decision }}</span>
|
||||
<span>{{ a.legalApproval.approverName }} · {{ a.legalApproval.date }}</span>
|
||||
<span>{{ a.legalApproval.remarks }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="loan-approval-tile"
|
||||
[class.loan-approval-tile--approved]="
|
||||
a.postCheckApproval.decision === 'APPROVED' || a.postCheckApproval.decision === 'EXCEPTION_APPROVED'
|
||||
"
|
||||
[class.loan-approval-tile--rejected]="a.postCheckApproval.decision === 'REJECTED'"
|
||||
[class.loan-approval-tile--pending]="a.postCheckApproval.decision === 'PENDING'"
|
||||
>
|
||||
<span class="loan-approval-tile__role">Post-Disbursement Check</span>
|
||||
<span class="loan-approval-tile__decision">{{ a.postCheckApproval.decision }}</span>
|
||||
<span>{{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }}</span>
|
||||
<span>{{ a.postCheckApproval.remarks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Application Journey — Date-wise Audit Trail</div>
|
||||
<div class="npa-timeline" style="padding: 4px 18px 18px">
|
||||
@for (h of a.stageHistory; track h.date + h.stage + h.outcome) {
|
||||
<div [class]="historyToneClass(h.outcome)">
|
||||
<span class="npa-timeline-item__icon">{{ stepMarker(a, h.stage) }}</span>
|
||||
<div class="npa-timeline-item__body">
|
||||
<div class="npa-timeline-item__row">
|
||||
<span class="npa-timeline-item__type">{{ stageLabels[h.stage] }} — {{ h.outcome }}</span>
|
||||
<span class="npa-timeline-item__date">{{ h.date }}</span>
|
||||
</div>
|
||||
<p class="npa-timeline-item__remarks">{{ h.remarks }}</p>
|
||||
<span class="npa-timeline-item__channel">{{ h.actionBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="pg-empty">No activity recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Application Not Found</div>
|
||||
<div class="loan-action-card">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import {
|
||||
ApprovalField,
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
DiscrepancyType,
|
||||
DISCREPANCY_LABELS,
|
||||
EmiScheduleRow,
|
||||
LoanApplication,
|
||||
LOAN_APPLICATIONS,
|
||||
OriginationStage,
|
||||
STAGE_LABELS,
|
||||
STAGE_ORDER,
|
||||
STATUS_LABELS,
|
||||
allotShareMembership,
|
||||
buildEmiSchedule,
|
||||
calcEligibleAmount,
|
||||
calculateEmi,
|
||||
checkCreditScore,
|
||||
cibilBand,
|
||||
CIC_BUREAUS,
|
||||
clearPersonalDiscussionAndProceed,
|
||||
confirmChequeAch,
|
||||
decideApproval,
|
||||
disburseApplication,
|
||||
flagDiscrepancy,
|
||||
isRevolvingProduct,
|
||||
markCustomerSigned,
|
||||
progressPct,
|
||||
pushFieldInquiry,
|
||||
resolveDiscrepancyAndProceed,
|
||||
sanctioningAuthority,
|
||||
setMonthlyIncome,
|
||||
stageBadgeClass,
|
||||
stageIndex,
|
||||
proceedToNextStage,
|
||||
recordVerification,
|
||||
todayDDMMYYYY,
|
||||
uploadAgentReport,
|
||||
uploadCicReport
|
||||
} from '../loan-origination-data';
|
||||
|
||||
/**
|
||||
* A single application's full sanctioning journey — the stage stepper, the current stage's
|
||||
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
|
||||
* signing / disbursement), and the complete date-wise audit trail. Every action here mutates the
|
||||
* SAME LoanApplication object the Pipeline list reads, so the two screens can never disagree.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-application-detail',
|
||||
templateUrl: './application-detail.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../../investment/investment-shared.scss',
|
||||
'../../npa/npa-shared.scss',
|
||||
'../loan-origination-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
readonly stageOrder = STAGE_ORDER;
|
||||
readonly stageLabels = STAGE_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly discrepancyLabels = DISCREPANCY_LABELS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
application: LoanApplication | undefined;
|
||||
|
||||
/** Shared remarks box for whichever action card is currently on screen. */
|
||||
actionRemarks = '';
|
||||
discrepancySelection: DiscrepancyType = 'NONE';
|
||||
disbursementMode = 'Account Credit';
|
||||
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
|
||||
monthlyIncomeInput: number | null = null;
|
||||
|
||||
currentUserName = 'Branch User';
|
||||
|
||||
/** True for a Head Office login, which can open any branch's application — a branch-level
|
||||
* login is locked to its own branch, the same split every other report in the app uses. */
|
||||
isHeadOffice = false;
|
||||
private ownBranchCode: string | null = null;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
private sub?: Subscription;
|
||||
|
||||
ngOnInit(): void {
|
||||
const creds = this.auth.getCredentials();
|
||||
this.currentUserName = creds?.username || 'Branch User';
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
if (!this.isHeadOffice) {
|
||||
const officeName = creds?.officeName || '';
|
||||
this.ownBranchCode =
|
||||
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
|
||||
}
|
||||
this.sub = this.route.paramMap.subscribe((params) => {
|
||||
const applicationNo = params.get('applicationNo');
|
||||
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
|
||||
// A branch-level login can only open applications belonging to its own branch — treated
|
||||
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
|
||||
// that a differently-branched application exists at all.
|
||||
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
|
||||
this.actionRemarks = '';
|
||||
this.discrepancySelection = 'NONE';
|
||||
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub?.unsubscribe();
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/loan-origination/pipeline']);
|
||||
}
|
||||
|
||||
// -------------------- Stepper --------------------
|
||||
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
|
||||
const idx = stageIndex(stage);
|
||||
const curIdx = stageIndex(a.currentStage);
|
||||
if (stage === a.currentStage && a.overallStatus === 'REJECTED') {
|
||||
return 'loan-step loan-step--rejected';
|
||||
}
|
||||
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
|
||||
return 'loan-step loan-step--hold';
|
||||
}
|
||||
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||
return 'loan-step loan-step--done';
|
||||
}
|
||||
if (idx === curIdx) {
|
||||
return 'loan-step loan-step--current';
|
||||
}
|
||||
return 'loan-step';
|
||||
}
|
||||
|
||||
stepMarker(a: LoanApplication, stage: OriginationStage): string {
|
||||
const idx = stageIndex(stage);
|
||||
const curIdx = stageIndex(a.currentStage);
|
||||
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||
return '✓';
|
||||
}
|
||||
return String(idx + 1);
|
||||
}
|
||||
|
||||
progress(a: LoanApplication): number {
|
||||
return progressPct(a);
|
||||
}
|
||||
|
||||
/** Colours an audit-trail entry's icon badge by what actually happened — a rejection/hold
|
||||
* reads as an alert, everything else (approvals, verifications, uploads, signings) reads
|
||||
* as positive progress. */
|
||||
historyToneClass(outcome: string): string {
|
||||
if (/reject|hold/i.test(outcome)) {
|
||||
return 'npa-timeline-item npa-timeline-item--alert';
|
||||
}
|
||||
return 'npa-timeline-item npa-timeline-item--positive';
|
||||
}
|
||||
|
||||
badgeClass(a: LoanApplication): string {
|
||||
return stageBadgeClass(a);
|
||||
}
|
||||
|
||||
cibilBandOf(score: number): string {
|
||||
return cibilBand(score);
|
||||
}
|
||||
|
||||
readonly cicBureaus = CIC_BUREAUS;
|
||||
|
||||
checkCreditScoreNow(a: LoanApplication): void {
|
||||
checkCreditScore(a, this.currentUserName);
|
||||
}
|
||||
|
||||
sanctioningAuthorityOf(a: LoanApplication): string {
|
||||
return sanctioningAuthority(a.requestedAmount);
|
||||
}
|
||||
|
||||
// -------------------- Generic stage-advance actions --------------------
|
||||
proceed(a: LoanApplication, remarks: string): void {
|
||||
proceedToNextStage(a, this.currentUserName, remarks);
|
||||
}
|
||||
|
||||
// -------------------- KYC & Documents --------------------
|
||||
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
|
||||
* Monthly Income box — before the officer actually commits it via "Assess Income". */
|
||||
eligiblePreview(a: LoanApplication): number {
|
||||
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
|
||||
}
|
||||
|
||||
setIncomeNow(a: LoanApplication): void {
|
||||
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
|
||||
return;
|
||||
}
|
||||
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
|
||||
}
|
||||
|
||||
allKycDone(a: LoanApplication): boolean {
|
||||
const base =
|
||||
a.aadhaarVerified &&
|
||||
a.panVerified &&
|
||||
a.aadhaarFingerprintCaptured &&
|
||||
a.incomeProofSubmitted &&
|
||||
a.bankStatementSubmitted &&
|
||||
a.electricityBillSubmitted &&
|
||||
a.monthlyIncome > 0;
|
||||
return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
|
||||
}
|
||||
|
||||
// -------------------- Field Investigation --------------------
|
||||
fiComplete(a: LoanApplication): boolean {
|
||||
const addressOk = a.addressVerification.status === 'VERIFIED';
|
||||
const officeOk = a.officeVerification.status === 'VERIFIED';
|
||||
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
|
||||
return addressOk && officeOk && propertyOk;
|
||||
}
|
||||
|
||||
completeFi(a: LoanApplication): void {
|
||||
const agent = 'Field Verification Team';
|
||||
const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
|
||||
'addressVerification',
|
||||
'officeVerification',
|
||||
'propertyVerification'
|
||||
];
|
||||
for (const key of keys) {
|
||||
if (a[key].status !== 'PENDING') {
|
||||
recordVerification(
|
||||
a,
|
||||
key,
|
||||
a[key].status,
|
||||
agent,
|
||||
a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
|
||||
);
|
||||
}
|
||||
}
|
||||
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
|
||||
}
|
||||
|
||||
// -------------------- Personal Discussion --------------------
|
||||
clearPersonalDiscussion(a: LoanApplication): void {
|
||||
clearPersonalDiscussionAndProceed(
|
||||
a,
|
||||
this.currentUserName,
|
||||
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
flagPersonalDiscussion(a: LoanApplication): void {
|
||||
const notes =
|
||||
this.actionRemarks.trim() ||
|
||||
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
|
||||
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
|
||||
this.actionRemarks = '';
|
||||
this.discrepancySelection = 'NONE';
|
||||
}
|
||||
|
||||
resolveDiscrepancy(a: LoanApplication): void {
|
||||
resolveDiscrepancyAndProceed(
|
||||
a,
|
||||
this.currentUserName,
|
||||
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
// -------------------- Share Membership --------------------
|
||||
allotMembership(a: LoanApplication): void {
|
||||
allotShareMembership(a, this.currentUserName);
|
||||
}
|
||||
|
||||
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
||||
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||
decideApproval(
|
||||
a,
|
||||
field,
|
||||
'APPROVED',
|
||||
this.currentUserName,
|
||||
approverRole,
|
||||
this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||
decideApproval(
|
||||
a,
|
||||
field,
|
||||
'REJECTED',
|
||||
this.currentUserName,
|
||||
approverRole,
|
||||
this.actionRemarks.trim() || 'Declined on review.'
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
exceptionApprove(a: LoanApplication): void {
|
||||
decideApproval(
|
||||
a,
|
||||
'postCheckApproval',
|
||||
'EXCEPTION_APPROVED',
|
||||
this.currentUserName,
|
||||
'Post-Disbursement Check',
|
||||
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
|
||||
);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
legalChecklistDone(a: LoanApplication): boolean {
|
||||
return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
|
||||
}
|
||||
|
||||
committeeReportsReady(a: LoanApplication): boolean {
|
||||
return (
|
||||
a.cicReportUploaded &&
|
||||
a.backgroundCheckDone &&
|
||||
(!a.secured || a.cersaiSearchDone) &&
|
||||
a.aadhaarVerified &&
|
||||
a.panVerified &&
|
||||
a.incomeProofSubmitted &&
|
||||
this.fiComplete(a) &&
|
||||
a.managerMeetingDone
|
||||
);
|
||||
}
|
||||
|
||||
riskChecklistReady(a: LoanApplication): boolean {
|
||||
return (
|
||||
a.addressVerification.status === 'VERIFIED' &&
|
||||
a.incomeProofSubmitted &&
|
||||
a.previousLoanDeclarationDone &&
|
||||
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
|
||||
this.fiComplete(a) &&
|
||||
a.backgroundCheckDone
|
||||
);
|
||||
}
|
||||
|
||||
postCheckSignaturesDone(a: LoanApplication): boolean {
|
||||
return (
|
||||
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- CIC Upload --------------------
|
||||
onCicFileSelected(event: Event, a: LoanApplication): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
uploadCicReport(a, file.name, this.currentUserName);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
pushFieldInquiryNow(a: LoanApplication): void {
|
||||
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
|
||||
}
|
||||
|
||||
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
uploadAgentReport(a, file.name, this.currentUserName);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
|
||||
markSigned(a: LoanApplication): void {
|
||||
markCustomerSigned(a, a.applicantName);
|
||||
}
|
||||
|
||||
confirmChequeAndProceed(a: LoanApplication): void {
|
||||
confirmChequeAch(a, this.currentUserName);
|
||||
}
|
||||
|
||||
disburseNow(a: LoanApplication): void {
|
||||
disburseApplication(a, this.currentUserName, this.disbursementMode);
|
||||
}
|
||||
|
||||
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
|
||||
isRevolving(a: LoanApplication): boolean {
|
||||
return isRevolvingProduct(a.productCode);
|
||||
}
|
||||
|
||||
emiAmountFor(a: LoanApplication): number {
|
||||
return calculateEmi(a.requestedAmount, a.interestRate, a.tenureMonths);
|
||||
}
|
||||
|
||||
emiScheduleFor(a: LoanApplication): EmiScheduleRow[] {
|
||||
return buildEmiSchedule(a.requestedAmount, a.interestRate, a.tenureMonths, a.disbursementDate);
|
||||
}
|
||||
|
||||
totalInterestFor(a: LoanApplication): number {
|
||||
return this.emiScheduleFor(a).reduce((s, r) => s + r.interestComponent, 0);
|
||||
}
|
||||
|
||||
totalPayableFor(a: LoanApplication): number {
|
||||
return a.requestedAmount + this.totalInterestFor(a);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
|
||||
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'loan-origination',
|
||||
data: { title: 'Loan Origination', breadcrumb: 'Loan Origination' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'pipeline',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'pipeline',
|
||||
component: LoanPipelineComponent,
|
||||
data: { title: 'Loan Origination Pipeline', breadcrumb: 'Pipeline' }
|
||||
},
|
||||
{
|
||||
path: 'application/:applicationNo',
|
||||
component: ApplicationDetailComponent,
|
||||
data: { title: 'Loan Application', breadcrumb: 'Application' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class LoanOriginationRoutingModule {}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Loan Origination-specific styling layered on top of ../cashier/cashier-shared.scss (pg-*,
|
||||
// cctr-*) and ../npa/npa-shared.scss (npa-timeline-* — reused as-is for the application's stage
|
||||
// history/audit trail, since it's the same generic "date-led event log" primitive already built
|
||||
// for the NPA Recovery Journey popup, not NPA-specific).
|
||||
|
||||
// EMI Repayment Schedule can run to 240 rows (a 20-year Housing Loan) — scrolls within a fixed
|
||||
// height with the header pinned, rather than pushing the rest of the page far down.
|
||||
.loan-emi-table-scroll {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.loan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
&--progress {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--hold {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--disbursed {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-btn--success {
|
||||
background: linear-gradient(135deg, #22c55e, #15803d);
|
||||
color: #fff;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-btn--danger {
|
||||
background: #fff;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-btn--warn {
|
||||
background: #fff;
|
||||
border-color: #fde68a;
|
||||
color: #b45309;
|
||||
|
||||
&:hover {
|
||||
background: #fffbeb;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage stepper — the 12-stage sanctioning pipeline, drawn as a connected row of
|
||||
// dots so an application's progress reads at a glance.
|
||||
// ---------------------------------------------------------------------------
|
||||
.loan-stepper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding: 22px 18px 16px;
|
||||
}
|
||||
|
||||
.loan-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 90px;
|
||||
position: relative;
|
||||
|
||||
&__connector {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: -50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&:first-child &__connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-top: 8px;
|
||||
font-size: 10.5px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
max-width: 92px;
|
||||
}
|
||||
|
||||
&--done {
|
||||
.loan-step__dot {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loan-step__connector {
|
||||
background: #86efac;
|
||||
}
|
||||
|
||||
.loan-step__label {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--current {
|
||||
.loan-step__dot {
|
||||
background: #4338ca;
|
||||
border-color: #4338ca;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgb(67 56 202 / 15%);
|
||||
}
|
||||
|
||||
.loan-step__label {
|
||||
color: #4338ca;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
.loan-step__dot {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loan-step__label {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&--hold {
|
||||
.loan-step__dot {
|
||||
background: #d97706;
|
||||
border-color: #d97706;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loan-step__label {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action card — the current stage's checklist/inputs and Approve/Reject buttons.
|
||||
// ---------------------------------------------------------------------------
|
||||
.loan-action-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.loan-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.loan-checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
|
||||
// stylelint-disable-next-line selector-no-qualifying-type
|
||||
input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #4338ca;
|
||||
}
|
||||
}
|
||||
|
||||
.loan-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.loan-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.loan-kv-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
dt {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.loan-approval-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 4px 18px 18px;
|
||||
}
|
||||
|
||||
.loan-approval-tile {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__role {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__decision {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--approved .loan-approval-tile__decision {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--rejected .loan-approval-tile__decision {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--pending .loan-approval-tile__decision {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { LoanOriginationRoutingModule } from './loan-origination-routing.module';
|
||||
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
|
||||
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
LoanOriginationRoutingModule,
|
||||
LoanPipelineComponent,
|
||||
ApplicationDetailComponent
|
||||
]
|
||||
})
|
||||
export class LoanOriginationModule {}
|
||||
@@ -0,0 +1,570 @@
|
||||
<!--
|
||||
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="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Loan Origination Pipeline</h1>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field
|
||||
Investigation, Personal Discussion, Credit Committee, Risk & Legal Approval — as on {{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
|
||||
<option value="">-- All Branches --</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
} @else {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<span class="pg-chip">{{ scopeName }}</span>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="inv-kpis npa-kpis--ratio">
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Applications</span>
|
||||
<span class="inv-kpi__value">{{ totalApplications }}</span>
|
||||
<span class="inv-kpi__sub">in the active pipeline</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">In Progress</span>
|
||||
<span class="inv-kpi__value">{{ inProgressCount }}</span>
|
||||
<span class="inv-kpi__sub">moving through the workflow</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">On Hold</span>
|
||||
<span class="inv-kpi__value npa-warn">{{ onHoldCount }}</span>
|
||||
<span class="inv-kpi__sub">discrepancy under resolution</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Rejected</span>
|
||||
<span class="inv-kpi__value npa-warn">{{ rejectedCount }}</span>
|
||||
<span class="inv-kpi__sub">declined at Committee/Risk</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Disbursed</span>
|
||||
<span class="inv-kpi__value">{{ disbursedApplications.length }}</span>
|
||||
<span class="inv-kpi__sub">₹{{ disbursedAmount | number: '1.0-0' }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'pipeline'"
|
||||
(click)="setView('pipeline')"
|
||||
>
|
||||
Pipeline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'new'"
|
||||
(click)="setView('new')"
|
||||
>
|
||||
New Application
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'channel'"
|
||||
(click)="setView('channel')"
|
||||
>
|
||||
Channel-wise Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'bc'"
|
||||
(click)="setView('bc')"
|
||||
>
|
||||
BC-wise Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'share'"
|
||||
(click)="setView('share')"
|
||||
>
|
||||
Share Member Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'target'"
|
||||
(click)="setView('target')"
|
||||
>
|
||||
Target vs Actual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== PIPELINE ===================== -->
|
||||
@if (activeView === 'pipeline') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Filters</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Channel</span>
|
||||
<select [(ngModel)]="channelFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
<option value="BRANCH">Branch</option>
|
||||
<option value="BC">Business Correspondent (BC)</option>
|
||||
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Stage</span>
|
||||
<select [(ngModel)]="stageFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
@for (s of stageOrder; track s) {
|
||||
<option [value]="s">{{ stageLabels[s] }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="statusFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="ON_HOLD">On Hold</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="DISBURSED">Disbursed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Search (Application No / Applicant)</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="currentPage = 1"
|
||||
placeholder="e.g. Ramesh, LN0102..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Branch</th>
|
||||
<th>Channel</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th>Current Stage</th>
|
||||
<th class="num">Progress</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(filteredApplications); track a.applicationNo) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.applicantName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>{{ channelLabels[a.channel] }}</td>
|
||||
<td>{{ a.productName }}</td>
|
||||
<td class="num">₹{{ a.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||
<td class="num">{{ progress(a) }}%</td>
|
||||
<td>
|
||||
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No applications match these filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (filteredApplications.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of
|
||||
{{ filteredApplications.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, filteredApplications)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(filteredApplications) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(filteredApplications)"
|
||||
(click)="goToPage(currentPage + 1, filteredApplications)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== NEW APPLICATION ===================== -->
|
||||
@if (activeView === 'new') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">New Application — Channel Intake</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>* Channel</span>
|
||||
<select [(ngModel)]="newApp.channel">
|
||||
<option value="BRANCH">Branch</option>
|
||||
<option value="BC">Business Correspondent (BC)</option>
|
||||
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (newApp.channel === 'BC') {
|
||||
<label class="inv-form-field">
|
||||
<span>* BC Agent / Point</span>
|
||||
<input type="text" [(ngModel)]="newApp.bcAgentName" placeholder="e.g. Suvidha BC Point — Andheri" />
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>* Customer Type</span>
|
||||
<select [(ngModel)]="newApp.customerType">
|
||||
<option value="NEW">New Customer</option>
|
||||
<option value="EXISTING">Existing Customer</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Applicant Name</span>
|
||||
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Branch</span>
|
||||
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
|
||||
@for (b of newApplicationBranches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select [(ngModel)]="newApp.productCode">
|
||||
@for (p of products; track p.code) {
|
||||
<option [value]="p.code">{{ p.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Amount</span>
|
||||
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Tenure (Months)</span>
|
||||
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== CHANNEL-WISE REPORT ===================== -->
|
||||
@if (activeView === 'channel') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Channel-wise Report — Branch / BC / Self-Apply</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th class="num">Applications</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th class="num col-in">Disbursed Count</th>
|
||||
<th class="num col-in">Disbursed Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of channelReport; track r.channel) {
|
||||
<tr>
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>Total</td>
|
||||
<td class="num">{{ totalApplications }}</td>
|
||||
<td class="num">
|
||||
₹{{
|
||||
channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount
|
||||
| number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">{{ disbursedApplications.length }}</td>
|
||||
<td class="num">₹{{ disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== BC-WISE REPORT ===================== -->
|
||||
@if (activeView === 'bc') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
BC-wise Report — Business Correspondent Sourced Applications ({{ bcApplications.length }})
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>BC Agent / Point</th>
|
||||
<th class="num">Applications</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th class="num col-in">Disbursed Count</th>
|
||||
<th class="num col-in">Disbursed Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bcReport; track r.bcAgentName) {
|
||||
<tr>
|
||||
<td>{{ r.bcAgentName }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No BC-sourced applications in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== SHARE MEMBER REPORT ===================== -->
|
||||
@if (activeView === 'share') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Member Report</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
A cooperative-bank borrower must hold membership shares in the society before a loan can be sanctioned.
|
||||
{{ shareMemberCount }} of {{ totalApplications }} applicants are already members; the rest need membership
|
||||
allotted before Credit Committee approval — done from the application's own detail screen.
|
||||
</p>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Stage</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(nonMemberApplications); track a.applicationNo) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.applicantName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||
<td>
|
||||
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">Every active applicant in this branch already holds membership.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (nonMemberApplications.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of
|
||||
{{ nonMemberApplications.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, nonMemberApplications)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(nonMemberApplications)"
|
||||
(click)="goToPage(currentPage + 1, nonMemberApplications)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== TARGET VS ACTUAL ===================== -->
|
||||
@if (activeView === 'target') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Disbursement Target — Annual</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Target</th>
|
||||
<th class="num col-in">Disbursed (Actual)</th>
|
||||
<th class="num">Achievement %</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of branchTargetRows; track row.branchCode) {
|
||||
<tr>
|
||||
<td>{{ row.branchName }}</td>
|
||||
<td class="num">₹{{ row.target | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ row.achievementPct | number: '1.1-1' }}%</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
KCC and Cash Credit (MSME) targets are RBI's actual Priority Sector Lending sub-targets — Agriculture 18% and
|
||||
Micro Enterprises 7.5% of Adjusted Net Bank Credit (shown here against this module's own mix, since RBI sets
|
||||
them against the bank's whole loan book, not one product line). The rest are internal business-plan targets, not
|
||||
RBI-mandated.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Target Basis</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num col-in">Actual %</th>
|
||||
<th class="num">Variance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of productTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td>
|
||||
<span class="loan-badge" [class.loan-badge--progress]="row.isRbiMandated">
|
||||
{{ row.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Channel Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num col-in">Actual %</th>
|
||||
<th class="num">Variance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of channelTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
branchTargetAmount,
|
||||
Channel,
|
||||
CHANNEL_LABELS,
|
||||
CHANNEL_TARGET_PCT,
|
||||
CustomerType,
|
||||
LoanApplication,
|
||||
LOAN_APPLICATIONS,
|
||||
ORIGINATION_PRODUCTS,
|
||||
OriginationStage,
|
||||
OverallStatus,
|
||||
PRODUCT_TARGET_PCT,
|
||||
STAGE_LABELS,
|
||||
STAGE_ORDER,
|
||||
STATUS_LABELS,
|
||||
TARGET_STATUS_LABELS,
|
||||
TargetStatus,
|
||||
createApplication,
|
||||
progressPct,
|
||||
stageBadgeClass,
|
||||
targetStatus,
|
||||
todayDDMMYYYY
|
||||
} from '../loan-origination-data';
|
||||
|
||||
interface ChannelReportRow {
|
||||
channel: Channel;
|
||||
label: string;
|
||||
count: number;
|
||||
requestedAmount: number;
|
||||
disbursedCount: number;
|
||||
disbursedAmount: number;
|
||||
}
|
||||
|
||||
interface BcReportRow {
|
||||
bcAgentName: string;
|
||||
count: number;
|
||||
requestedAmount: number;
|
||||
disbursedCount: number;
|
||||
disbursedAmount: number;
|
||||
}
|
||||
|
||||
interface BranchTargetRow {
|
||||
branchCode: string;
|
||||
branchName: string;
|
||||
target: number;
|
||||
actual: number;
|
||||
achievementPct: number;
|
||||
status: TargetStatus;
|
||||
}
|
||||
|
||||
interface MixTargetRow {
|
||||
code: string;
|
||||
label: string;
|
||||
targetPct: number;
|
||||
actualPct: number;
|
||||
variancePct: number;
|
||||
status: TargetStatus;
|
||||
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
|
||||
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
|
||||
isRbiMandated: boolean;
|
||||
}
|
||||
|
||||
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
|
||||
|
||||
/**
|
||||
* Loan Origination pipeline — every application currently moving through the sanctioning
|
||||
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
||||
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
|
||||
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
|
||||
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
|
||||
* Application Detail screen mutates, so the pipeline view can never drift from an application's
|
||||
* real state.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-pipeline',
|
||||
templateUrl: './loan-pipeline.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../../investment/investment-shared.scss',
|
||||
'../../npa/npa-shared.scss',
|
||||
'../loan-origination-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class LoanPipelineComponent implements OnInit {
|
||||
readonly stageOrder = STAGE_ORDER;
|
||||
readonly stageLabels = STAGE_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly channelLabels = CHANNEL_LABELS;
|
||||
readonly branches = BRANCHES;
|
||||
readonly products = ORIGINATION_PRODUCTS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
activeView: ViewId = 'pipeline';
|
||||
|
||||
isHeadOffice = false;
|
||||
currentOfficeName = 'Branch Office';
|
||||
private ownBranchCode: string | null = null;
|
||||
branchFilter = '';
|
||||
|
||||
channelFilter = '';
|
||||
stageFilter = '';
|
||||
statusFilter = '';
|
||||
searchText = '';
|
||||
|
||||
readonly pageSize = 20;
|
||||
currentPage = 1;
|
||||
|
||||
generating = false;
|
||||
|
||||
newApp = {
|
||||
channel: 'BRANCH' as Channel,
|
||||
bcAgentName: '',
|
||||
customerType: 'NEW' as CustomerType,
|
||||
applicantName: '',
|
||||
branchCode: this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
requestedAmount: null as number | null,
|
||||
tenureMonths: null as number | null
|
||||
};
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
|
||||
if (requestedView && [
|
||||
'pipeline',
|
||||
'new',
|
||||
'channel',
|
||||
'bc',
|
||||
'share',
|
||||
'target'
|
||||
].includes(requestedView)) {
|
||||
this.activeView = requestedView;
|
||||
}
|
||||
|
||||
const creds = this.auth.getCredentials();
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
if (this.isHeadOffice) {
|
||||
this.branchFilter = '';
|
||||
} else {
|
||||
this.ownBranchCode =
|
||||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||||
?.code || null;
|
||||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||
// A branch-level login can only originate applications for its own branch — locked here,
|
||||
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||
this.newApp.branchCode = this.branchFilter;
|
||||
}
|
||||
}
|
||||
|
||||
/** Branches selectable on the New Application form — every branch for Head Office, only the
|
||||
* logged-in user's own branch otherwise. */
|
||||
get newApplicationBranches(): BranchMaster[] {
|
||||
return this.isHeadOffice ? this.branches : this.branches.filter((b) => b.code === this.branchFilter);
|
||||
}
|
||||
|
||||
setView(view: ViewId): void {
|
||||
this.activeView = view;
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
setBranch(code: string): void {
|
||||
this.branchFilter = code;
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
/** Name of the branch/scope currently being viewed, for display in the header. */
|
||||
get scopeName(): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.currentOfficeName;
|
||||
}
|
||||
return this.branchFilter
|
||||
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
|
||||
: 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
get scopedApplications(): LoanApplication[] {
|
||||
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
|
||||
}
|
||||
|
||||
get filteredApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => {
|
||||
if (this.channelFilter && a.channel !== this.channelFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.stageFilter && a.currentStage !== this.stageFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && a.overallStatus !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.searchText) {
|
||||
const q = this.searchText.toLowerCase();
|
||||
return a.applicationNo.toLowerCase().includes(q) || a.applicantName.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Pagination --------------------
|
||||
paged<T>(items: T[]): T[] {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
return items.slice(start, start + this.pageSize);
|
||||
}
|
||||
|
||||
pageCount(items: unknown[]): number {
|
||||
return Math.max(1, Math.ceil(items.length / this.pageSize));
|
||||
}
|
||||
|
||||
rangeStart(items: unknown[]): number {
|
||||
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||
}
|
||||
|
||||
rangeEnd(items: unknown[]): number {
|
||||
return Math.min(this.currentPage * this.pageSize, items.length);
|
||||
}
|
||||
|
||||
goToPage(page: number, items: unknown[]): void {
|
||||
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
|
||||
}
|
||||
|
||||
// -------------------- Portfolio KPIs --------------------
|
||||
get totalApplications(): number {
|
||||
return this.scopedApplications.length;
|
||||
}
|
||||
|
||||
get inProgressCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'IN_PROGRESS').length;
|
||||
}
|
||||
|
||||
get onHoldCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'ON_HOLD').length;
|
||||
}
|
||||
|
||||
get rejectedCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'REJECTED').length;
|
||||
}
|
||||
|
||||
get disbursedApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
}
|
||||
|
||||
get disbursedAmount(): number {
|
||||
return this.disbursedApplications.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
}
|
||||
|
||||
progress(a: LoanApplication): number {
|
||||
return progressPct(a);
|
||||
}
|
||||
|
||||
badgeClass(a: LoanApplication): string {
|
||||
return stageBadgeClass(a);
|
||||
}
|
||||
|
||||
statusLabel(status: OverallStatus): string {
|
||||
return STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
stageLabel(stage: OriginationStage): string {
|
||||
return STAGE_LABELS[stage];
|
||||
}
|
||||
|
||||
openApplication(a: LoanApplication): void {
|
||||
this.router.navigate([
|
||||
'/loan-origination/application',
|
||||
a.applicationNo
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------- Channel-wise / BC-wise / Share Member reports --------------------
|
||||
get channelReport(): ChannelReportRow[] {
|
||||
const channels: Channel[] = [
|
||||
'BRANCH',
|
||||
'BC',
|
||||
'SELF_APPLY'
|
||||
];
|
||||
return channels.map((channel) => {
|
||||
const rows = this.scopedApplications.filter((a) => a.channel === channel);
|
||||
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
return {
|
||||
channel,
|
||||
label: CHANNEL_LABELS[channel],
|
||||
count: rows.length,
|
||||
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||
disbursedCount: disbursed.length,
|
||||
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get bcApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.channel === 'BC');
|
||||
}
|
||||
|
||||
get bcReport(): BcReportRow[] {
|
||||
const names = Array.from(new Set(this.bcApplications.map((a) => a.bcAgentName))).sort();
|
||||
return names.map((bcAgentName) => {
|
||||
const rows = this.bcApplications.filter((a) => a.bcAgentName === bcAgentName);
|
||||
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
return {
|
||||
bcAgentName,
|
||||
count: rows.length,
|
||||
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||
disbursedCount: disbursed.length,
|
||||
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get shareMemberCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.isShareMember).length;
|
||||
}
|
||||
|
||||
get nonMemberApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => !a.isShareMember && a.overallStatus !== 'REJECTED');
|
||||
}
|
||||
|
||||
// -------------------- Disbursement Target vs Actual --------------------
|
||||
statusLabelOf(status: TargetStatus): string {
|
||||
return TARGET_STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
/** Branches shown in the target table: every branch for Head Office's consolidated view, only
|
||||
* the drilled-into/own branch otherwise — same scoping the rest of the screen already uses. */
|
||||
get branchTargetRows(): BranchTargetRow[] {
|
||||
const branchesToShow =
|
||||
this.isHeadOffice && !this.branchFilter
|
||||
? this.branches
|
||||
: this.branches.filter((b) => b.code === this.branchFilter);
|
||||
return branchesToShow.map((b) => {
|
||||
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
|
||||
(s, a) => s + a.requestedAmount,
|
||||
0
|
||||
);
|
||||
const target = branchTargetAmount(b.code);
|
||||
const achievementPct = target ? (actual / target) * 100 : 0;
|
||||
return {
|
||||
branchCode: b.code,
|
||||
branchName: b.name,
|
||||
target,
|
||||
actual,
|
||||
achievementPct,
|
||||
status: targetStatus(achievementPct, 100, 5)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get disbursedInScope(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
}
|
||||
|
||||
get productTargetRows(): MixTargetRow[] {
|
||||
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
return this.products.map((p) => {
|
||||
const productAmount = this.disbursedInScope
|
||||
.filter((a) => a.productCode === p.code)
|
||||
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const actualPct = total ? (productAmount / total) * 100 : 0;
|
||||
const targetPct = PRODUCT_TARGET_PCT[p.code] || 0;
|
||||
return {
|
||||
code: p.code,
|
||||
label: p.name,
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3),
|
||||
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get channelTargetRows(): MixTargetRow[] {
|
||||
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const channels: Channel[] = [
|
||||
'BRANCH',
|
||||
'BC',
|
||||
'SELF_APPLY'
|
||||
];
|
||||
return channels.map((channel) => {
|
||||
const channelAmount = this.disbursedInScope
|
||||
.filter((a) => a.channel === channel)
|
||||
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const actualPct = total ? (channelAmount / total) * 100 : 0;
|
||||
const targetPct = CHANNEL_TARGET_PCT[channel];
|
||||
return {
|
||||
code: channel,
|
||||
label: CHANNEL_LABELS[channel],
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3),
|
||||
isRbiMandated: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- New Application intake --------------------
|
||||
productOf(code: string) {
|
||||
return this.products.find((p) => p.code === code) || this.products[0];
|
||||
}
|
||||
|
||||
saveNewApplication(): void {
|
||||
if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) {
|
||||
this.toast('Enter the applicant name and a valid requested amount.');
|
||||
return;
|
||||
}
|
||||
if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) {
|
||||
this.toast('Enter the BC agent/point name for a Business Correspondent application.');
|
||||
return;
|
||||
}
|
||||
// A branch-level login is locked to its own branch regardless of what the (disabled) select
|
||||
// is bound to — defence in depth against the DOM value ever being tampered with.
|
||||
if (!this.isHeadOffice && this.ownBranchCode) {
|
||||
this.newApp.branchCode = this.ownBranchCode;
|
||||
}
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||
const product = this.productOf(this.newApp.productCode);
|
||||
const seq = LOAN_APPLICATIONS.length + 20000;
|
||||
const app = createApplication(
|
||||
{
|
||||
channel: this.newApp.channel,
|
||||
bcAgentName: this.newApp.bcAgentName || '—',
|
||||
customerType: this.newApp.customerType,
|
||||
applicantName: this.newApp.applicantName.trim(),
|
||||
branchCode: branch.code,
|
||||
branchName: branch.name,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
secured: product.secured,
|
||||
requestedAmount: this.newApp.requestedAmount,
|
||||
tenureMonths: this.newApp.tenureMonths || product.minTenure,
|
||||
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
|
||||
},
|
||||
seq
|
||||
);
|
||||
LOAN_APPLICATIONS.unshift(app);
|
||||
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
||||
this.newApp = {
|
||||
channel: 'BRANCH',
|
||||
bcAgentName: '',
|
||||
customerType: 'NEW',
|
||||
applicantName: '',
|
||||
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
requestedAmount: null,
|
||||
tenureMonths: null
|
||||
};
|
||||
this.router.navigate([
|
||||
'/loan-origination/application',
|
||||
app.applicationNo
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Pipeline',
|
||||
title: 'Loan Origination Pipeline',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Application No',
|
||||
'Applicant',
|
||||
'Branch',
|
||||
'Channel',
|
||||
'Product',
|
||||
'Requested Amount',
|
||||
'Current Stage',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
18,
|
||||
24,
|
||||
14
|
||||
],
|
||||
rows: this.filteredApplications.map((a) => [
|
||||
a.applicationNo,
|
||||
a.applicantName,
|
||||
a.branchName,
|
||||
CHANNEL_LABELS[a.channel],
|
||||
a.productName,
|
||||
a.requestedAmount,
|
||||
STAGE_LABELS[a.currentStage],
|
||||
STATUS_LABELS[a.overallStatus]
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Channel-wise',
|
||||
title: 'Channel-wise Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Channel',
|
||||
'Applications',
|
||||
'Requested Amount',
|
||||
'Disbursed Count',
|
||||
'Disbursed Amount'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.channelReport.map((r) => [
|
||||
r.label,
|
||||
r.count,
|
||||
r.requestedAmount,
|
||||
r.disbursedCount,
|
||||
r.disbursedAmount
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'BC-wise',
|
||||
title: 'BC-wise Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'BC Agent / Point',
|
||||
'Applications',
|
||||
'Requested Amount',
|
||||
'Disbursed Count',
|
||||
'Disbursed Amount'
|
||||
],
|
||||
widths: [
|
||||
30,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.bcReport.map((r) => [
|
||||
r.bcAgentName,
|
||||
r.count,
|
||||
r.requestedAmount,
|
||||
r.disbursedCount,
|
||||
r.disbursedAmount
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Share Member',
|
||||
title: 'Share Member Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Application No',
|
||||
'Applicant',
|
||||
'Branch',
|
||||
'Current Stage',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
24,
|
||||
14
|
||||
],
|
||||
rows: this.nonMemberApplications.map((a) => [
|
||||
a.applicationNo,
|
||||
a.applicantName,
|
||||
a.branchName,
|
||||
STAGE_LABELS[a.currentStage],
|
||||
STATUS_LABELS[a.overallStatus]
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Branch Target',
|
||||
title: 'Branch-wise Disbursement Target — Annual',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Branch',
|
||||
'Target',
|
||||
'Disbursed (Actual)',
|
||||
'Achievement %',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
20,
|
||||
18,
|
||||
18,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.branchTargetRows.map((r) => [
|
||||
r.branchName,
|
||||
r.target,
|
||||
r.actual,
|
||||
Number(r.achievementPct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Product-Channel Target',
|
||||
title: 'Product & Channel Mix — Target % vs Actual %',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Mix',
|
||||
'Target Basis',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Variance (pp)',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
18,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: [
|
||||
...this.productTargetRows.map((r) => [
|
||||
`Product — ${r.label}`,
|
||||
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
]),
|
||||
...this.channelTargetRows.map((r) => [
|
||||
`Channel — ${r.label}`,
|
||||
'Internal Target',
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
])
|
||||
]
|
||||
}
|
||||
];
|
||||
await exportWorkbook(sheets, `Loan-Origination-${this.scopeName}-${this.asOnDate}`);
|
||||
} catch (err) {
|
||||
console.error('Loan Origination export failed', err);
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -956,7 +956,7 @@ export interface ReportMeta {
|
||||
label: string;
|
||||
}
|
||||
export const REPORT_META: ReportMeta[] = [
|
||||
{ id: 'interest', group: 'reports', label: 'NPA Interest' },
|
||||
{ id: 'interest', group: 'reports', label: 'NPA Report' },
|
||||
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
|
||||
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
|
||||
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<!-- ===================== 01 NPA INTEREST ===================== -->
|
||||
@if (activeReport === 'interest') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">NPA Interest — Income Reversed on Non-Performing Accounts</div>
|
||||
<div class="pos-section-label">NPA Report — Income Reversed on Non-Performing Accounts</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -264,16 +264,22 @@
|
||||
<div class="pos-section-label">Record a Recovery Against an NPA Account</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>* NPA Account</span>
|
||||
<select [(ngModel)]="newRecovery.accountNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (a of recoveryOptions; track a.accountNo) {
|
||||
<option [value]="a.accountNo">
|
||||
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
|
||||
</option>
|
||||
<span>Account Type</span>
|
||||
<select [ngModel]="recoveryProductFilter" (ngModelChange)="setRecoveryProductFilter($event)">
|
||||
<option value="">-- All Account Types --</option>
|
||||
@for (p of recoveryProductOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* NPA Account</span>
|
||||
<mifosx-search-select
|
||||
[options]="recoveryAccountSearchOptions"
|
||||
[(ngModel)]="newRecovery.accountNo"
|
||||
placeholder="Search account no. or customer name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Recovery Date</span>
|
||||
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
|
||||
@@ -299,45 +305,66 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
||||
gnpaAmount | number: '1.0-0'
|
||||
}}
|
||||
GNPA outstanding
|
||||
<div
|
||||
class="pos-section-label"
|
||||
style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
|
||||
>
|
||||
<span>
|
||||
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
||||
gnpaAmount | number: '1.0-0'
|
||||
}}
|
||||
GNPA outstanding
|
||||
</span>
|
||||
@if (recoveryProductFilter) {
|
||||
<span class="pg-chip">Filtered: {{ recoveryProductFilter }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="pg-sub" style="padding: 8px 16px 12px">
|
||||
Target Recovery is the account's contractual due this month (EMI / interest-servicing / bullet amount) — same
|
||||
basis as the Recovery Management Dashboard.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Account Type</th>
|
||||
<th>NPA Category</th>
|
||||
<th>Account No</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Mode</th>
|
||||
<th class="num col-in">Recovered Amount</th>
|
||||
<th class="num">Target Recovery</th>
|
||||
<th class="num col-in">Recovered Amount (Actual)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
|
||||
<tr>
|
||||
<td>{{ r.date }}</td>
|
||||
<td>{{ r.productName }}</td>
|
||||
<td>
|
||||
<span [class]="r.npaCategoryClass">{{ r.npaCategory }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.accountNo }}</span>
|
||||
</td>
|
||||
<td>{{ r.customerName }}</td>
|
||||
<td>{{ r.branchName }}</td>
|
||||
<td>{{ r.mode }}</td>
|
||||
<td class="num">₹{{ r.targetRecovery | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td>
|
||||
<td colspan="9" class="pg-empty">No recoveries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total Recovered</td>
|
||||
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td>
|
||||
<td colspan="7">Total (Target once per account · Actual across all entries shown)</td>
|
||||
<td class="num">₹{{ recoveryTargetTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ recoveryActualTotal | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component';
|
||||
import {
|
||||
LOAN_ACCOUNTS,
|
||||
BRANCHES,
|
||||
@@ -79,9 +80,16 @@ interface RecoveryRow {
|
||||
accountNo: string;
|
||||
customerName: string;
|
||||
branchName: string;
|
||||
productName: string;
|
||||
npaCategory: string;
|
||||
npaCategoryClass: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
mode: string;
|
||||
/** What the account was contractually due to pay this month (EMI / interest-servicing /
|
||||
* bullet), the same "Target" basis used by the Recovery Management Dashboard — shown
|
||||
* alongside the actual recovered amount so a desk can see over/under-achievement per entry. */
|
||||
targetRecovery: number;
|
||||
}
|
||||
|
||||
interface RepaymentTypeSummaryRow {
|
||||
@@ -119,7 +127,8 @@ interface AgeingBucketRow {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
FontAwesomeModule
|
||||
FontAwesomeModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
@@ -457,25 +466,75 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
return this.npaAccounts.filter((a) => a.recoveries.length > 0);
|
||||
}
|
||||
|
||||
/** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so
|
||||
* it's quicker to find the right account when adding a new entry) and the Recovery Register
|
||||
* below — a single control instead of two that could drift out of sync. */
|
||||
recoveryProductFilter = '';
|
||||
|
||||
get recoveryProductOptions(): string[] {
|
||||
return [...new Set(this.npaAccounts.map((a) => a.productName))].sort();
|
||||
}
|
||||
|
||||
setRecoveryProductFilter(productName: string): void {
|
||||
this.recoveryProductFilter = productName;
|
||||
this.currentPage = 1;
|
||||
if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) {
|
||||
this.newRecovery.accountNo = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */
|
||||
get recoveryOptions(): LoanAccount[] {
|
||||
return this.npaAccounts;
|
||||
return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter);
|
||||
}
|
||||
|
||||
/** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no,
|
||||
* customer name and branch, not just the collapsed label text. */
|
||||
get recoveryAccountSearchOptions(): SearchSelectOption[] {
|
||||
return this.recoveryOptions.map((a) => ({
|
||||
value: a.accountNo,
|
||||
label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`,
|
||||
searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}`
|
||||
}));
|
||||
}
|
||||
|
||||
get recoveryRows(): RecoveryRow[] {
|
||||
return this.recoveryAccounts
|
||||
.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter)
|
||||
.flatMap((a) =>
|
||||
a.recoveries.map((r) => ({
|
||||
accountNo: a.accountNo,
|
||||
customerName: a.customerName,
|
||||
branchName: a.branchName,
|
||||
productName: a.productName,
|
||||
npaCategory: this.stageLabel(this.stage(a)),
|
||||
npaCategoryClass: this.badgeClass(this.stage(a)),
|
||||
date: r.date,
|
||||
amount: r.amount,
|
||||
mode: r.mode
|
||||
mode: r.mode,
|
||||
targetRecovery: expectedRecoveryThisMonth(a)
|
||||
}))
|
||||
)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
/** The accounts behind recoveryRows, honouring the Account Type filter — used to total the
|
||||
* monthly Target once per account rather than once per recovery transaction (an account with
|
||||
* two recovery entries must not have its target counted twice). */
|
||||
get recoveryFilteredAccounts(): LoanAccount[] {
|
||||
return this.recoveryAccounts.filter(
|
||||
(a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter
|
||||
);
|
||||
}
|
||||
|
||||
get recoveryTargetTotal(): number {
|
||||
return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0);
|
||||
}
|
||||
|
||||
get recoveryActualTotal(): number {
|
||||
return this.recoveryRows.reduce((s, r) => s + r.amount, 0);
|
||||
}
|
||||
|
||||
// -------------------- Portfolio-wide KPIs (shown on every screen) --------------------
|
||||
get totalAdvances(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
@@ -616,8 +675,11 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
|
||||
/** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only
|
||||
* shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA
|
||||
* accounts still have their own dedicated Day-wise Overdue report. */
|
||||
get stageSummary(): StageSummaryRow[] {
|
||||
return STAGE_ORDER.map((stage) => {
|
||||
return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => {
|
||||
const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
|
||||
return {
|
||||
stage,
|
||||
@@ -649,15 +711,15 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get grandTotalAccounts(): number {
|
||||
return this.allAccounts.length;
|
||||
return this.stageSummary.reduce((s, row) => s + row.count, 0);
|
||||
}
|
||||
|
||||
get grandTotalOutstanding(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
||||
return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
|
||||
}
|
||||
|
||||
get grandTotalProvision(): number {
|
||||
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0);
|
||||
return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0);
|
||||
}
|
||||
|
||||
get bankWideTotalAccounts(): number {
|
||||
@@ -754,8 +816,8 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
case 'interest':
|
||||
return [
|
||||
{
|
||||
sheetName: 'NPA Interest',
|
||||
title: 'NPA Interest — Income Reversed on Non-Performing Accounts',
|
||||
sheetName: 'NPA Report',
|
||||
title: 'NPA Report — Income Reversed on Non-Performing Accounts',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Account No',
|
||||
@@ -865,39 +927,53 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
||||
{
|
||||
sheetName: 'NPA Recovery',
|
||||
title: 'NPA Recovery Register',
|
||||
subtitle,
|
||||
subtitle: this.recoveryProductFilter
|
||||
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
|
||||
: subtitle,
|
||||
headers: [
|
||||
'Date',
|
||||
'Account Type',
|
||||
'NPA Category',
|
||||
'Account No',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Mode',
|
||||
'Recovered Amount'
|
||||
'Target Recovery',
|
||||
'Recovered Amount (Actual)'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
18,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
18,
|
||||
18
|
||||
16,
|
||||
20
|
||||
],
|
||||
rows: [
|
||||
...this.recoveryRows.map((r) => [
|
||||
r.date,
|
||||
r.productName,
|
||||
r.npaCategory,
|
||||
r.accountNo,
|
||||
r.customerName,
|
||||
r.branchName,
|
||||
r.mode,
|
||||
r.targetRecovery,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'Total Recovered',
|
||||
'Total (Target once per account · Actual across all entries)',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.totalRecoveredAmount
|
||||
'',
|
||||
'',
|
||||
this.recoveryTargetTotal,
|
||||
this.recoveryActualTotal
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface SearchSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
|
||||
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
|
||||
* that aren't all visible in the collapsed label. */
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
|
||||
* it is impractical (e.g. hundreds of loan accounts) — a text box that filters the option list
|
||||
* as you type, instead of a native dropdown you can only scroll through. Selection only commits
|
||||
* on click; typing without picking an option reverts the box to the last committed selection on
|
||||
* blur, so the bound ngModel value never drifts from an actual, valid option.
|
||||
*
|
||||
* The option list is positioned `fixed` and anchored to the input's own on-screen coordinates
|
||||
* (recomputed on open/scroll/resize) rather than `absolute` inside the component — most of this
|
||||
* app's cards use `overflow: hidden` for rounded corners, which would otherwise clip a dropdown
|
||||
* that extends past the card's edge.
|
||||
*
|
||||
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-search-select',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SearchSelectComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="inv-search-select">
|
||||
<input
|
||||
#inputEl
|
||||
type="text"
|
||||
class="inv-search-select__input"
|
||||
[value]="query"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="disabled"
|
||||
(input)="onInput($event)"
|
||||
(focus)="onFocus()"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
@if (open) {
|
||||
<ul
|
||||
class="inv-search-select__list"
|
||||
[style.top.px]="listTop"
|
||||
[style.left.px]="listLeft"
|
||||
[style.width.px]="listWidth"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
>
|
||||
@for (o of filteredOptions; track o.value) {
|
||||
<li
|
||||
class="inv-search-select__option"
|
||||
[class.inv-search-select__option--active]="o.value === value"
|
||||
(click)="selectOption(o)"
|
||||
>
|
||||
{{ o.label }}
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="inv-search-select__empty">No matches</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
|
||||
* component's own template, so its look is re-declared here rather than inherited. */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-search-select__input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.inv-search-select__list {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
|
||||
}
|
||||
.inv-search-select__option {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.inv-search-select__option:hover,
|
||||
.inv-search-select__option--active {
|
||||
background: #eef2ff;
|
||||
}
|
||||
.inv-search-select__empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SearchSelectComponent implements ControlValueAccessor {
|
||||
@Input() options: SearchSelectOption[] = [];
|
||||
@Input() placeholder = 'Search…';
|
||||
|
||||
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value = '';
|
||||
query = '';
|
||||
open = false;
|
||||
disabled = false;
|
||||
|
||||
listTop = 0;
|
||||
listLeft = 0;
|
||||
listWidth = 0;
|
||||
|
||||
private onChangeFn: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
get filteredOptions(): SearchSelectOption[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return this.options;
|
||||
}
|
||||
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private labelFor(value: string): string {
|
||||
return this.options.find((o) => o.value === value)?.label ?? '';
|
||||
}
|
||||
|
||||
writeValue(v: string): void {
|
||||
this.value = v ?? '';
|
||||
this.query = this.labelFor(this.value);
|
||||
}
|
||||
|
||||
registerOnChange(fn: (v: string) => void): void {
|
||||
this.onChangeFn = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
/** Anchors the fixed-position list to the input's current on-screen box. */
|
||||
private updateListPosition(): void {
|
||||
const el = this.inputEl?.nativeElement;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
this.listTop = rect.bottom + 4;
|
||||
this.listLeft = rect.left;
|
||||
this.listWidth = rect.width;
|
||||
}
|
||||
|
||||
@HostListener('window:scroll')
|
||||
@HostListener('window:resize')
|
||||
onWindowChange(): void {
|
||||
if (this.open) {
|
||||
this.updateListPosition();
|
||||
}
|
||||
}
|
||||
|
||||
onInput(event: Event): void {
|
||||
this.query = (event.target as HTMLInputElement).value;
|
||||
this.open = true;
|
||||
this.updateListPosition();
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
this.open = true;
|
||||
this.updateListPosition();
|
||||
}
|
||||
|
||||
onBlur(): void {
|
||||
this.open = false;
|
||||
this.onTouched();
|
||||
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
|
||||
this.query = this.labelFor(this.value);
|
||||
}
|
||||
|
||||
selectOption(o: SearchSelectOption): void {
|
||||
this.value = o.value;
|
||||
this.query = o.label;
|
||||
this.open = false;
|
||||
this.onChangeFn(this.value);
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
+45
@@ -115,6 +115,51 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="spa-group">
|
||||
<div class="spa-group__header">
|
||||
<h4>Map Payment Types to Specific Funds</h4>
|
||||
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
|
||||
</div>
|
||||
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
|
||||
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
|
||||
<div class="spa-other-row-wrap">
|
||||
<div class="spa-row spa-row--other" [formGroupName]="i">
|
||||
<div class="spa-row__meta spa-row__meta--other">
|
||||
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Payment Type</mat-label>
|
||||
<mat-select formControlName="paymentTypeId">
|
||||
@for (paymentType of paymentTypeOptions; track paymentType.id) {
|
||||
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="spa-row__actions spa-row__actions--other">
|
||||
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Fund Source</mat-label>
|
||||
<mat-select formControlName="fundSourceAccountId">
|
||||
@for (glAccount of fundSourceAccountOptions; track glAccount.id) {
|
||||
<mat-option [value]="glAccount.id">
|
||||
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="spa-remove-btn spa-remove-btn--side"
|
||||
(click)="removePaymentFundSourceMapping(i)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+52
-1
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
|
||||
glAccountId: number | null;
|
||||
};
|
||||
|
||||
type PaymentFundSourceValue = {
|
||||
paymentTypeId: number | null;
|
||||
fundSourceAccountId: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-saving-product-accounting-step',
|
||||
templateUrl: './saving-product-accounting-step.component.html',
|
||||
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
expenseAccountOptions: GLAccount[] = [];
|
||||
incomeAccountOptions: GLAccount[] = [];
|
||||
assetAccountOptions: GLAccount[] = [];
|
||||
paymentTypeOptions: any[] = [];
|
||||
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
|
||||
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
|
||||
* Asset account, so this field accepts either. */
|
||||
fundSourceAccountOptions: GLAccount[] = [];
|
||||
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
||||
|
||||
constructor() {
|
||||
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
||||
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
||||
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
||||
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
|
||||
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
|
||||
this.buildAccountingSectionConfigs();
|
||||
this.savingProductAccountingForm.patchValue({
|
||||
accountingRule: accountingRuleId
|
||||
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
|
||||
this.syncAccountingControls(accountingRuleId);
|
||||
this.patchAccountingValues(mappings);
|
||||
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
|
||||
this.setOverdraftControl();
|
||||
this.setDormancyControl();
|
||||
}
|
||||
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||
expensesOtherGlMappings: this.formBuilder.array([]),
|
||||
incomeOtherGlMappings: this.formBuilder.array([])
|
||||
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||
paymentChannelToFundSourceMappings: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
get paymentChannelToFundSourceMappings(): UntypedFormArray {
|
||||
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
|
||||
}
|
||||
|
||||
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
|
||||
return this.formBuilder.group({
|
||||
paymentTypeId: [
|
||||
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
|
||||
Validators.required
|
||||
],
|
||||
fundSourceAccountId: [
|
||||
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private patchPaymentFundSourceMappings(mappings: any[]): void {
|
||||
this.paymentChannelToFundSourceMappings.clear();
|
||||
(mappings || []).forEach((mapping) => {
|
||||
this.paymentChannelToFundSourceMappings.push(
|
||||
this.createPaymentFundSourceGroup({
|
||||
paymentTypeId: mapping.paymentType?.id,
|
||||
fundSourceAccountId: mapping.fundSourceAccount?.id
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
addPaymentFundSourceMapping(): void {
|
||||
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
|
||||
}
|
||||
|
||||
removePaymentFundSourceMapping(index: number): void {
|
||||
this.paymentChannelToFundSourceMappings.removeAt(index);
|
||||
}
|
||||
|
||||
private setConditionalControls() {
|
||||
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
||||
this.syncAccountingControls(accountingRule);
|
||||
|
||||
+9
-2
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
liabilityAccountData: GLAccount[] = [];
|
||||
incomeAccountData: GLAccount[] = [];
|
||||
equityAccountData: GLAccount[] = [];
|
||||
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||
* either instead of Asset-only. */
|
||||
referenceAccountData: GLAccount[] = [];
|
||||
|
||||
constructor() {
|
||||
this.createShareProductAccountingForm();
|
||||
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||
this.equityAccountData = opts.equityAccountOptions || [];
|
||||
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
|
||||
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
rows: [
|
||||
{
|
||||
key: 'shareReferenceId',
|
||||
label: 'Share Reference (Cash Vault)',
|
||||
label: 'Share Reference (Cash Vault or Suspense)',
|
||||
inputLabel: 'Share reference',
|
||||
controlName: 'shareReferenceId',
|
||||
options: this.assetAccountData,
|
||||
options: this.referenceAccountData,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -53,6 +53,24 @@ const routes: Routes = [
|
||||
(m) => m.FormDeclarationReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'risk-review',
|
||||
data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' },
|
||||
loadComponent: () =>
|
||||
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'tds-interest',
|
||||
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
|
||||
loadComponent: () =>
|
||||
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'tds-remittance',
|
||||
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
|
||||
loadComponent: () =>
|
||||
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
|
||||
},
|
||||
{
|
||||
path: ':filter',
|
||||
data: { routeParamBreadcrumb: 'filter' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
<!--
|
||||
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="rr-root">
|
||||
<!-- HEADER -->
|
||||
<div class="rr-head">
|
||||
<div class="rr-head-left">
|
||||
<fa-icon icon="user-shield" class="rr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="rr-head-t">Risk Review Report</h2>
|
||||
<div class="rr-head-s">
|
||||
Customers whose transactions tripped an AML rule and whose risk profile therefore needs a look. For each case
|
||||
the reviewer decides one of four things — keep the rating, move it, call for a KYC update, or refer it to the
|
||||
Principal Officer for an STR.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-head-actions">
|
||||
<button class="rr-btn rr-btn--primary" [disabled]="generating || loading" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (sampleCount) {
|
||||
<div class="rr-banner rr-banner--sample">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
|
||||
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
|
||||
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
|
||||
</span>
|
||||
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="rr-filters">
|
||||
<div class="rr-filter-row">
|
||||
<div class="rr-modes">
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button class="rr-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@switch (periodMode) {
|
||||
@case ('day') {
|
||||
<label class="rr-field">
|
||||
<span>Date</span>
|
||||
<input type="date" [(ngModel)]="filterDay" />
|
||||
</label>
|
||||
}
|
||||
@case ('week') {
|
||||
<label class="rr-field">
|
||||
<span>Any date in week</span>
|
||||
<input type="date" [(ngModel)]="filterWeek" />
|
||||
</label>
|
||||
<span class="rr-resolved">Week: {{ rangeLabel }}</span>
|
||||
}
|
||||
@case ('month') {
|
||||
<label class="rr-field">
|
||||
<span>Month</span>
|
||||
<input type="month" [(ngModel)]="filterMonth" />
|
||||
</label>
|
||||
}
|
||||
@default {
|
||||
<label class="rr-field">
|
||||
<span>Alerts from</span>
|
||||
<input type="date" [(ngModel)]="filterFrom" />
|
||||
</label>
|
||||
<label class="rr-field">
|
||||
<span>To</span>
|
||||
<input type="date" [(ngModel)]="filterTo" />
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rr-filter-row">
|
||||
<label class="rr-field">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
|
||||
@if (canSelectBranch) {
|
||||
<option value="">All Branches</option>
|
||||
}
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Current risk</span>
|
||||
<select [(ngModel)]="currentRisk">
|
||||
<option value="">Any rating</option>
|
||||
<option value="LOW">Low</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HIGH">High</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>AML rule triggered</span>
|
||||
<select [(ngModel)]="ruleCode">
|
||||
<option value="">All rules</option>
|
||||
@for (r of rules; track r.code) {
|
||||
<option [value]="r.code">{{ r.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Review status</span>
|
||||
<select [(ngModel)]="status">
|
||||
<option value="">Any status</option>
|
||||
@for (s of reviewStatuses; track s) {
|
||||
<option [value]="s">{{ statusMeta[s].label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Rating outcome</span>
|
||||
<select [(ngModel)]="direction">
|
||||
<option value="">Any outcome</option>
|
||||
<option value="up">Review upward</option>
|
||||
<option value="down">Review downward</option>
|
||||
<option value="same">Within current rating</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field rr-field--grow">
|
||||
<span>Search</span>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Customer name, customer ID, account number or alert ID…" />
|
||||
</label>
|
||||
|
||||
<label class="rr-check">
|
||||
<input type="checkbox" [(ngModel)]="openOnly" />
|
||||
Only cases awaiting review
|
||||
</label>
|
||||
|
||||
<button class="rr-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
@if (!canSelectBranch) {
|
||||
<div class="rr-scope-note">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Showing {{ branchLabel }} only. Head Office logins can select any branch.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="rr-loading"><fa-icon icon="shield-alt"></fa-icon> Loading AML alerts…</div>
|
||||
} @else {
|
||||
<!-- SUMMARY -->
|
||||
<div class="rr-kpis">
|
||||
<div class="rr-kpi">
|
||||
<div class="rr-kpi-n">{{ summary.customers }}</div>
|
||||
<div class="rr-kpi-l">Customers for review</div>
|
||||
<div class="rr-kpi-sub">
|
||||
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-kpi rr-kpi--blue">
|
||||
<div class="rr-kpi-n">{{ summary.alerts }}</div>
|
||||
<div class="rr-kpi-l">AML alerts triggered</div>
|
||||
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
|
||||
</div>
|
||||
<div
|
||||
class="rr-kpi rr-kpi--amber"
|
||||
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
|
||||
>
|
||||
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
|
||||
<div class="rr-kpi-l">Ratings to be moved</div>
|
||||
<div class="rr-kpi-sub">
|
||||
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
|
||||
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
|
||||
<div class="rr-kpi-l">Escalated to STR</div>
|
||||
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RULE MIX — doubles as a one-click filter -->
|
||||
@if (summary.byRule.length) {
|
||||
<div class="rr-mix">
|
||||
<span class="rr-mix-t">Rules triggered</span>
|
||||
@for (r of summary.byRule; track r.code) {
|
||||
<button class="rr-mix-item" [class.active]="ruleCode === r.code" (click)="toggleRule(r.code)">
|
||||
{{ r.label }} <strong>{{ r.count }}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- VIEW SWITCH -->
|
||||
<div class="rr-views">
|
||||
<button class="rr-chip" [class.active]="view === 'customer'" (click)="view = 'customer'">
|
||||
<fa-icon icon="users"></fa-icon> Customer wise
|
||||
</button>
|
||||
<button class="rr-chip" [class.active]="view === 'alert'" (click)="view = 'alert'">
|
||||
<fa-icon icon="list"></fa-icon> Alert wise
|
||||
</button>
|
||||
<button class="rr-chip" [class.active]="view === 'txn'" (click)="view = 'txn'">
|
||||
<fa-icon icon="filter"></fa-icon> Transaction wise
|
||||
</button>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-count">
|
||||
{{
|
||||
view === 'customer'
|
||||
? customerRows.length + ' customers'
|
||||
: view === 'alert'
|
||||
? alertRows.length + ' alerts'
|
||||
: txnRows.length + ' transactions'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- CUSTOMER WISE -->
|
||||
@if (view === 'customer') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th class="num">Alerts</th>
|
||||
<th>AML Rules Triggered</th>
|
||||
<th class="num">Amount under review</th>
|
||||
<th>Last Alert</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of customerRows; track c.clientId) {
|
||||
<tr
|
||||
class="rr-cust"
|
||||
[class.open]="expanded === c.clientId"
|
||||
[class.rr-row--escalated]="c.escalated"
|
||||
(click)="toggle(c.clientId)"
|
||||
>
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ c.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ c.customerName }}</strong>
|
||||
@if (c.openCount > 0) {
|
||||
<span class="rr-tag">{{ c.openCount }} open</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ c.accountNo || '—' }}</td>
|
||||
<td>{{ c.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.alertCount }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
@for (label of c.ruleLabels; track label) {
|
||||
<span class="rr-rule">{{ label }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
|
||||
<td class="mono">{{ c.lastAlertDate }}</td>
|
||||
<td class="rr-verdict-cell">
|
||||
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
|
||||
@if (c.directionReason) {
|
||||
<div class="rr-verdict-why">{{ c.directionReason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (expanded === c.clientId) {
|
||||
<tr class="rr-detail">
|
||||
<td colspan="11">
|
||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||
@for (a of c.alerts; track a.alertId) {
|
||||
<div class="rr-case">
|
||||
<div class="rr-case-head">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
</div>
|
||||
<div class="rr-case-why">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
|
||||
</div>
|
||||
|
||||
<table class="rr-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of a.txns; track t.txnId) {
|
||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td>
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
@if (a.txns.length > 1) {
|
||||
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="rr-case-foot">
|
||||
<div class="rr-suggest">
|
||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||
</div>
|
||||
<div class="rr-case-remark">
|
||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||
{{ a.remarks || 'no remarks recorded' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="rr-empty">
|
||||
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ALERT WISE -->
|
||||
@if (view === 'alert') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alert ID</th>
|
||||
<th>Alert Date</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th>Transaction Details</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Review Status</th>
|
||||
<th>Reviewer Remarks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of alertRows; track a.alertId) {
|
||||
<tr [class.rr-row--escalated]="a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'">
|
||||
<td class="mono">{{ a.alertId }}</td>
|
||||
<td class="mono">{{ a.alertDate }}</td>
|
||||
<td class="mono">{{ a.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ a.customerName }}</strong>
|
||||
@if (!a.live) {
|
||||
<span class="rr-tag">Sample</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ a.accountNo || '—' }}</td>
|
||||
<td>{{ a.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(a.currentRisk)">{{ riskText(a.currentRisk) }}</span>
|
||||
</td>
|
||||
<td>{{ txnDetail(a) }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<div class="rr-rule-desc">{{ a.rule.description }}</div>
|
||||
</td>
|
||||
<td class="num strong">{{ a.amount | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
@if (a.reviewedBy) {
|
||||
<div class="rr-by">by {{ a.reviewedBy }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ a.remarks || '—' }}
|
||||
<div class="rr-suggest"><fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="12" class="rr-empty">
|
||||
No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TRANSACTION WISE — every entry, and the rule it tripped -->
|
||||
@if (view === 'txn') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
<th>Alert</th>
|
||||
<th>Review Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txnRows; track t.txnId) {
|
||||
<tr [class.rr-row--escalated]="t.status === 'ESCALATED_STR' || t.status === 'STR_FILED'">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td class="mono">{{ t.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ t.customerName }}</strong>
|
||||
</td>
|
||||
<td class="mono">{{ t.accountNo || '—' }}</td>
|
||||
<td>{{ t.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(t.currentRisk)">{{ riskText(t.currentRisk) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
<span class="rr-rule">{{ t.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ t.rule.category }}</span>
|
||||
@if (t.legCount > 1) {
|
||||
<span class="rr-leg">leg {{ t.legIndex }} of {{ t.legCount }}</span>
|
||||
}
|
||||
<div class="rr-rule-desc">{{ t.rule.description }}</div>
|
||||
</td>
|
||||
<td class="mono">{{ t.alertId }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(t.status)">{{ statusMeta[t.status].label }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="16" class="rr-empty">
|
||||
No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="rr-legend">
|
||||
<span class="rr-status rr-status--open">Pending review</span> awaiting a reviewer ·
|
||||
<span class="rr-status rr-status--ok">Risk retained</span> explained, rating unchanged ·
|
||||
<span class="rr-status rr-status--warn">Risk upgraded / KYC update</span> profile changed ·
|
||||
<span class="rr-status rr-status--danger">Escalated / STR filed</span> with the Principal Officer
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="rr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--r-navy: #1a237e;
|
||||
--r-blue: #1d4ed8;
|
||||
--r-blue-l: #eff6ff;
|
||||
--r-green: #15803d;
|
||||
--r-green-l: #dcfce7;
|
||||
--r-amber: #b45309;
|
||||
--r-amber-l: #fef3c7;
|
||||
--r-red: #c0392b;
|
||||
--r-red-l: #fdecea;
|
||||
--r-violet: #6d28d9;
|
||||
--r-violet-l: #f3e8ff;
|
||||
--r-g50: #f8fafc;
|
||||
--r-g100: #f1f5f9;
|
||||
--r-g200: #e2e8f0;
|
||||
--r-g300: #cbd5e1;
|
||||
--r-g500: #64748b;
|
||||
--r-g700: #334155;
|
||||
--r-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: var(--r-g50);
|
||||
color: var(--r-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.rr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rr-head-ic {
|
||||
font-size: 26px;
|
||||
color: var(--r-navy);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.rr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rr-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.rr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 15px;
|
||||
border: 1px solid var(--r-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--r-g50);
|
||||
border-color: var(--r-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--r-green);
|
||||
border-color: var(--r-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
border-color: #12692f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 5px 11px;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* BANNER */
|
||||
.rr-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--sample {
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
.rr-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rr-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rr-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--r-g300);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--r-navy);
|
||||
border-color: var(--r-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--r-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--r-g900);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rr-resolved {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.rr-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
padding-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rr-scope-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--r-amber);
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 7px;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
/* LOADING */
|
||||
.rr-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 60px;
|
||||
color: var(--r-g500);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.rr-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-left: 4px solid var(--r-navy);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: var(--r-blue);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--r-red);
|
||||
}
|
||||
|
||||
&--dim {
|
||||
border-left-color: var(--r-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.rr-kpi-l {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
.rr-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* RULE MIX */
|
||||
.rr-mix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rr-mix-t {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--r-g500);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.rr-mix-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--r-g200);
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
strong {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--r-navy);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--r-navy);
|
||||
border-color: var(--r-navy);
|
||||
color: #fff;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* VIEW SWITCH */
|
||||
.rr-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rr-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rr-count {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.rr-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--r-g100);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--r-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--r-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.rr-detail) {
|
||||
background: var(--r-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-cust {
|
||||
cursor: pointer;
|
||||
|
||||
&.open {
|
||||
background: var(--r-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-row--escalated {
|
||||
background: var(--r-red-l);
|
||||
}
|
||||
|
||||
.rr-detail {
|
||||
background: #fcfcfe;
|
||||
|
||||
td {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--r-g50);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
border-top: 1px solid var(--r-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* One alert inside an expanded customer: rule, why, entries, outcome. */
|
||||
.rr-case {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 9px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-case-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rr-case-id {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
.rr-case-amt {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rr-case-why {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
font-size: 11.5px;
|
||||
color: #78350f;
|
||||
background: var(--r-amber-l);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rr-case-foot {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 7px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px dashed var(--r-g200);
|
||||
}
|
||||
|
||||
.rr-case-remark {
|
||||
font-size: 11.5px;
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
/* Channel of a single entry */
|
||||
.rr-chan {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Position of an entry within a multi-leg pattern alert */
|
||||
.rr-leg {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-blue-l);
|
||||
color: var(--r-blue);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rr-leg--ctr {
|
||||
background: var(--r-red-l);
|
||||
}
|
||||
|
||||
.cr {
|
||||
color: var(--r-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr {
|
||||
color: var(--r-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Rule cell */
|
||||
.rr-rules-cell {
|
||||
min-width: 230px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.rr-rule {
|
||||
display: inline-block;
|
||||
margin: 0 5px 3px 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--r-violet-l);
|
||||
color: var(--r-violet);
|
||||
}
|
||||
|
||||
.rr-rule-cat {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
.rr-rule-desc {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rr-suggest {
|
||||
font-size: 11px;
|
||||
color: var(--r-blue);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rr-by {
|
||||
font-size: 10.5px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Risk rating badge */
|
||||
.rr-risk {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--low {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--r-amber-l);
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
|
||||
&--none {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* Review status badge */
|
||||
.rr-status {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--open {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
&--progress {
|
||||
background: var(--r-blue-l);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--r-amber-l);
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* What the report says should happen to the rating */
|
||||
.rr-verdict-cell {
|
||||
min-width: 210px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.rr-verdict {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&--up {
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
/* A rating coming down is good news, not a warning. */
|
||||
&--down {
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--str {
|
||||
color: var(--r-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-verdict-why {
|
||||
font-size: 10.5px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.rr-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
|
||||
&--ctr {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.rr-empty {
|
||||
padding: 44px;
|
||||
text-align: center;
|
||||
color: var(--r-g500);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rr-legend {
|
||||
font-size: 11.5px;
|
||||
color: var(--r-g500);
|
||||
margin: 10px 2px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.rr-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--r-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.rr-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../aml/aml.service';
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
AML_RULE_CATALOGUE,
|
||||
LiveAmlAlert,
|
||||
REVIEW_STATUSES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
ReviewStatus,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory,
|
||||
sampleRiskAlerts
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
RiskReviewSummary,
|
||||
RiskTxnView,
|
||||
buildRiskReview,
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
|
||||
/** Starting value for the memo, used before the first filter pass runs. */
|
||||
const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
customers: 0,
|
||||
alerts: 0,
|
||||
txns: 0,
|
||||
ctrReportable: 0,
|
||||
openAlerts: 0,
|
||||
escalatedCustomers: 0,
|
||||
upgradeCandidates: 0,
|
||||
downgradeCandidates: 0,
|
||||
totalAmount: 0,
|
||||
byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 },
|
||||
byRule: [],
|
||||
byStatus: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Risk Review Report — the AML/compliance worklist.
|
||||
*
|
||||
* Lists the customers who tripped an AML rule in the period, the transaction
|
||||
* that tripped it, the rating currently on their KYC record and where the
|
||||
* review has got to. The reviewer's decision on each case is one of: keep the
|
||||
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
||||
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
||||
*
|
||||
* Rows come from the live rule engine. If the engine has raised nothing (or is
|
||||
* unreachable) the report falls back to a deterministic sample set and says so
|
||||
* on screen and in the workbook, so sample rows are never taken for live data.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-review-report',
|
||||
templateUrl: './risk-review-report.component.html',
|
||||
styleUrls: ['./risk-review-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class RiskReviewReportComponent implements OnInit {
|
||||
private aml = inject(AmlService);
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
readonly rules = AML_RULE_CATALOGUE;
|
||||
readonly reviewStatuses = REVIEW_STATUSES;
|
||||
readonly statusMeta = REVIEW_STATUS_META;
|
||||
readonly riskLabel = RISK_LABEL;
|
||||
readonly channelLabel = channelLabel;
|
||||
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'range', label: 'Between dates' }
|
||||
];
|
||||
|
||||
// ── Source state ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Rows returned by the monitoring engine, mapped onto the report shape. */
|
||||
private liveAlerts: RiskAlertRow[] = [];
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* Live alerts and the demonstration set, in one worklist.
|
||||
*
|
||||
* Every row keeps its `live` flag and sample rows are tagged in the table, so
|
||||
* a reviewer can still tell them apart at a glance.
|
||||
*/
|
||||
private alerts: RiskAlertRow[] = [];
|
||||
|
||||
get hasLive(): boolean {
|
||||
return this.liveAlerts.length > 0;
|
||||
}
|
||||
|
||||
get sampleCount(): number {
|
||||
return this.alerts.filter((a) => !a.live).length;
|
||||
}
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Alerts raised today must be in view, so the default window runs to whichever
|
||||
* is later — the seeded period end or the real current date.
|
||||
*/
|
||||
private static readonly TODAY = new Date().toISOString().slice(0, 10);
|
||||
private static readonly WINDOW_END =
|
||||
RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd;
|
||||
|
||||
periodMode: PeriodMode = 'range';
|
||||
filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||
filterWeek = '2026-07-27';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
branchCode = '';
|
||||
currentRisk: RiskLevel | '' = '';
|
||||
ruleCode = '';
|
||||
status: ReviewStatus | '' = '';
|
||||
/** Which way the review says the rating should move. '' shows every customer. */
|
||||
direction: RatingDirection | '' = '';
|
||||
search = '';
|
||||
openOnly = false;
|
||||
|
||||
view: ViewMode = 'customer';
|
||||
/** Client ID of the customer row currently expanded. */
|
||||
expanded: number | null = null;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/** Live alerts first, then the demonstration set behind them. */
|
||||
private compose(): void {
|
||||
this.alerts = [
|
||||
...this.liveAlerts,
|
||||
...sampleRiskAlerts()
|
||||
];
|
||||
this.memoKey = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert payload carries no rating, so the customer's current risk is read
|
||||
* off their KYC record — one call per distinct customer, not per alert. A
|
||||
* client whose KYC cannot be read stays unrated rather than blocking the
|
||||
* report, so the table renders either way.
|
||||
*/
|
||||
private enrichCurrentRisk(rows: RiskAlertRow[]): void {
|
||||
const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null);
|
||||
if (!clientIds.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(
|
||||
clientIds.map((id) =>
|
||||
this.clients.getClientKyc(String(id), true).pipe(
|
||||
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
|
||||
catchError(() => of({ id, risk: null as RiskLevel | null }))
|
||||
)
|
||||
)
|
||||
).subscribe((results) => {
|
||||
const byClient = new Map(
|
||||
results.map((r) => [
|
||||
r.id,
|
||||
r.risk
|
||||
])
|
||||
);
|
||||
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
|
||||
this.compose();
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
get range(): DateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return { from: this.filterDay, to: this.filterDay };
|
||||
}
|
||||
if (this.periodMode === 'week') {
|
||||
return weekRange(this.filterWeek);
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
return monthRange(this.filterMonth);
|
||||
}
|
||||
return { from: this.filterFrom, to: this.filterTo };
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
return this.scope.scopeLabel(this.branchCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied at the data boundary rather than only in the UI, so a branch login
|
||||
* cannot widen its own scope by tampering with component state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
private get filter(): RiskReviewFilter {
|
||||
return {
|
||||
range: this.range,
|
||||
branchCode: this.effectiveBranch,
|
||||
currentRisk: this.currentRisk,
|
||||
ruleCode: this.ruleCode,
|
||||
status: this.status,
|
||||
direction: this.direction,
|
||||
search: this.search,
|
||||
openOnly: this.openOnly
|
||||
};
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'range';
|
||||
this.filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
this.filterWeek = '2026-07-27';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
this.currentRisk = '';
|
||||
this.ruleCode = '';
|
||||
this.status = '';
|
||||
this.direction = '';
|
||||
this.search = '';
|
||||
this.openOnly = false;
|
||||
this.expanded = null;
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The whole screen reads from one computed model. Change detection hits these
|
||||
* getters many times per cycle, so the result is memoised against the filter
|
||||
* rather than re-filtering thousands of rows on every binding.
|
||||
*/
|
||||
private memoKey = '';
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
}
|
||||
|
||||
get alertRows(): RiskAlertView[] {
|
||||
return this.model.alerts;
|
||||
}
|
||||
|
||||
get customerRows(): RiskCustomerRow[] {
|
||||
return this.model.customers;
|
||||
}
|
||||
|
||||
get txnRows(): RiskTxnView[] {
|
||||
return this.model.txns;
|
||||
}
|
||||
|
||||
get summary(): RiskReviewSummary {
|
||||
return this.model.summary;
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.expanded = this.expanded === clientId ? null : clientId;
|
||||
}
|
||||
|
||||
/** One-click drill from the rule mix strip into that rule. */
|
||||
toggleRule(code: string): void {
|
||||
this.ruleCode = this.ruleCode === code ? '' : code;
|
||||
this.expanded = null;
|
||||
}
|
||||
|
||||
// ── Presentation helpers ───────────────────────────────────────────────────
|
||||
|
||||
riskClass(level: RiskLevel | null): string {
|
||||
return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none';
|
||||
}
|
||||
|
||||
riskText(level: RiskLevel | null): string {
|
||||
return level ? RISK_LABEL[level] : 'Unrated';
|
||||
}
|
||||
|
||||
statusClass(status: ReviewStatus): string {
|
||||
return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`;
|
||||
}
|
||||
|
||||
/** What the review concludes should happen to the rating, in one phrase. */
|
||||
verdictText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'Escalated to STR';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return 'Within current rating';
|
||||
}
|
||||
|
||||
verdictClass(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'rr-verdict rr-verdict--str';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return 'rr-verdict rr-verdict--up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'rr-verdict rr-verdict--down';
|
||||
}
|
||||
return 'rr-verdict';
|
||||
}
|
||||
|
||||
/** Transaction detail as the reviewer would read it in one line. */
|
||||
txnDetail(a: RiskAlertView): string {
|
||||
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
||||
if (a.txnCount > 1) {
|
||||
return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`;
|
||||
}
|
||||
return `${channelLabel(a.channel)} of ${amount}`;
|
||||
}
|
||||
|
||||
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const s = this.summary;
|
||||
const subtitle =
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
|
||||
`Source: live AML monitoring engine` +
|
||||
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Summary',
|
||||
title: 'Risk Review Report — Summary',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Particulars',
|
||||
'Value'
|
||||
],
|
||||
widths: [
|
||||
56,
|
||||
22
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'Customers flagged for risk review',
|
||||
String(s.customers)
|
||||
],
|
||||
[
|
||||
'AML alerts triggered',
|
||||
String(s.alerts)
|
||||
],
|
||||
[
|
||||
'Transactions behind those alerts',
|
||||
String(s.txns)
|
||||
],
|
||||
[
|
||||
' of which cash entries at or above the ₹10,00,000 CTR limit',
|
||||
String(s.ctrReportable)
|
||||
],
|
||||
[
|
||||
'Alerts still awaiting a reviewer decision',
|
||||
String(s.openAlerts)
|
||||
],
|
||||
[
|
||||
'Customers rated below what the triggered rules point at (review upward)',
|
||||
String(s.upgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers rated above what the period’s activity warrants (review downward)',
|
||||
String(s.downgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers escalated to the Principal Officer (STR)',
|
||||
String(s.escalatedCustomers)
|
||||
],
|
||||
[
|
||||
'Total transaction value under review',
|
||||
s.totalAmount
|
||||
],
|
||||
[
|
||||
'',
|
||||
''
|
||||
],
|
||||
[
|
||||
'Customers currently rated Low',
|
||||
String(s.byRisk.LOW)
|
||||
],
|
||||
[
|
||||
'Customers currently rated Medium',
|
||||
String(s.byRisk.MEDIUM)
|
||||
],
|
||||
[
|
||||
'Customers currently rated High',
|
||||
String(s.byRisk.HIGH)
|
||||
],
|
||||
[
|
||||
'Customers with no rating on record',
|
||||
String(s.byRisk.UNRATED)
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
sheetName: 'Rules triggered',
|
||||
title: 'Risk Review Report — AML Rules Triggered',
|
||||
subtitle,
|
||||
headers: [
|
||||
'AML Rule Triggered',
|
||||
'Category',
|
||||
'Alerts',
|
||||
'What the rule looks for',
|
||||
'Action expected of the reviewer'
|
||||
],
|
||||
widths: [
|
||||
34,
|
||||
14,
|
||||
10,
|
||||
62,
|
||||
62
|
||||
],
|
||||
rows: s.byRule.map((r) => {
|
||||
const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code);
|
||||
return [
|
||||
r.label,
|
||||
meta?.category ?? '—',
|
||||
r.count,
|
||||
meta?.description ?? '—',
|
||||
meta?.suggestedAction ?? '—'
|
||||
];
|
||||
})
|
||||
},
|
||||
{
|
||||
sheetName: 'Customer wise',
|
||||
title: 'Risk Review Report — Customer wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Alerts',
|
||||
'AML Rules Triggered',
|
||||
'Total Amount',
|
||||
'Largest Alert',
|
||||
'First Alert',
|
||||
'Last Alert',
|
||||
'Rules Point At',
|
||||
'Recommended Risk',
|
||||
'Open Cases',
|
||||
'Position',
|
||||
'Basis'
|
||||
],
|
||||
widths: [
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
8,
|
||||
46,
|
||||
16,
|
||||
16,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
11,
|
||||
24,
|
||||
70
|
||||
],
|
||||
rows: this.customerRows.map((c) => [
|
||||
String(c.clientId),
|
||||
c.customerName,
|
||||
c.accountNo || '—',
|
||||
c.branchCode ? glBranchName(c.branchCode) : '—',
|
||||
this.riskText(c.currentRisk),
|
||||
c.alertCount,
|
||||
c.ruleLabels.join('; '),
|
||||
c.totalAmount,
|
||||
c.largestAmount,
|
||||
c.firstAlertDate,
|
||||
c.lastAlertDate,
|
||||
RISK_LABEL[c.proposedRisk],
|
||||
RISK_LABEL[c.recommendedRisk],
|
||||
c.openCount,
|
||||
this.verdictText(c),
|
||||
c.directionReason || '—'
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Alert wise',
|
||||
title: 'Risk Review Report — Alert wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Transaction Details',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Amount',
|
||||
'Review Status',
|
||||
'Reviewed By',
|
||||
'Reviewer Remarks',
|
||||
'Action Expected'
|
||||
],
|
||||
widths: [
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
60,
|
||||
60
|
||||
],
|
||||
rows: this.alertRows.map((a) => [
|
||||
a.alertId,
|
||||
a.alertDate,
|
||||
String(a.clientId),
|
||||
a.customerName,
|
||||
a.accountNo || '—',
|
||||
a.branchCode ? glBranchName(a.branchCode) : '—',
|
||||
this.riskText(a.currentRisk),
|
||||
this.txnDetail(a),
|
||||
a.rule.label,
|
||||
a.rule.category,
|
||||
a.amount,
|
||||
REVIEW_STATUS_META[a.status].label,
|
||||
a.reviewedBy || '—',
|
||||
a.remarks || '—',
|
||||
a.rule.suggestedAction
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Transaction wise',
|
||||
title: 'Risk Review Report — Transaction wise (rule triggered by each entry)',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Txn Date',
|
||||
'Txn ID',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Channel',
|
||||
'Dr/Cr',
|
||||
'Amount',
|
||||
'Balance After',
|
||||
'Narration',
|
||||
'Counterparty',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Leg',
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Review Status',
|
||||
'CTR'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
18,
|
||||
7,
|
||||
16,
|
||||
16,
|
||||
32,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
22,
|
||||
7
|
||||
],
|
||||
rows: this.txnRows.map((t) => [
|
||||
t.date,
|
||||
t.txnId,
|
||||
String(t.clientId),
|
||||
t.customerName,
|
||||
t.accountNo || '—',
|
||||
t.branchCode ? glBranchName(t.branchCode) : '—',
|
||||
this.riskText(t.currentRisk),
|
||||
channelLabel(t.channel),
|
||||
t.drCr,
|
||||
t.amount,
|
||||
t.balanceAfter || null,
|
||||
t.narration || '—',
|
||||
t.counterparty || '—',
|
||||
t.rule.label,
|
||||
t.rule.category,
|
||||
t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—',
|
||||
t.alertId,
|
||||
t.alertDate,
|
||||
REVIEW_STATUS_META[t.status].label,
|
||||
t.ctrReportable ? 'Yes' : ''
|
||||
])
|
||||
}
|
||||
],
|
||||
`Risk-Review-Report-${this.range.from}-to-${this.range.to}`
|
||||
);
|
||||
|
||||
this.toast(
|
||||
`Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}`
|
||||
);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 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 {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
RiskTxn,
|
||||
ReviewStatus,
|
||||
amlRuleMeta,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
|
||||
/** Which way the review says the rating should move. */
|
||||
export type RatingDirection = 'up' | 'down' | 'same';
|
||||
|
||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface RiskReviewFilter {
|
||||
range: DateRange;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** '' means every rating. */
|
||||
currentRisk: RiskLevel | '';
|
||||
/** '' means every rule. */
|
||||
ruleCode: string;
|
||||
/** '' means every review status. */
|
||||
status: ReviewStatus | '';
|
||||
/** '' means every customer, whichever way their rating should move. */
|
||||
direction: RatingDirection | '';
|
||||
/** Free text over customer name, customer ID, account number and alert ID. */
|
||||
search: string;
|
||||
/** Only cases a reviewer still has to act on. */
|
||||
openOnly: boolean;
|
||||
}
|
||||
|
||||
/** An alert joined to its rule catalogue entry, ready to render. */
|
||||
export interface RiskAlertView extends RiskAlertRow {
|
||||
rule: AmlRuleMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* One transaction carrying the alert and rule it belongs to.
|
||||
*
|
||||
* This is the row that answers "which AML rule did this transaction trip" —
|
||||
* the alert is flattened onto each of its entries so the same transaction can
|
||||
* be read on its own, without expanding a customer first.
|
||||
*/
|
||||
export interface RiskTxnView extends RiskTxn {
|
||||
alertId: string;
|
||||
alertDate: string;
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
status: ReviewStatus;
|
||||
remarks: string;
|
||||
rule: AmlRuleMeta;
|
||||
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
|
||||
legIndex: number;
|
||||
legCount: number;
|
||||
}
|
||||
|
||||
/** All of one customer's alerts in the period, rolled up. */
|
||||
export interface RiskCustomerRow {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
alerts: RiskAlertView[];
|
||||
alertCount: number;
|
||||
/** Sum of the amounts behind the alerts — the exposure under review. */
|
||||
totalAmount: number;
|
||||
largestAmount: number;
|
||||
/** Highest risk any of the triggered rules points at, before materiality. */
|
||||
proposedRisk: RiskLevel;
|
||||
/** What the review concludes the rating should be, after materiality. */
|
||||
recommendedRisk: RiskLevel;
|
||||
/** Whether that is above, below or the same as the rating on record. */
|
||||
direction: RatingDirection;
|
||||
/** One line explaining the recommendation, shown under the verdict. */
|
||||
directionReason: string;
|
||||
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
|
||||
ruleLabels: string[];
|
||||
/** Earliest and latest alert in the period. */
|
||||
firstAlertDate: string;
|
||||
lastAlertDate: string;
|
||||
openCount: number;
|
||||
/** True when any alert on the customer has gone to the Principal Officer. */
|
||||
escalated: boolean;
|
||||
/** True when the rules point above the rating currently on the KYC record. */
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
customers: number;
|
||||
alerts: number;
|
||||
/** Individual entries behind those alerts. */
|
||||
txns: number;
|
||||
/** Cash entries at or above the CTR limit on their own. */
|
||||
ctrReportable: number;
|
||||
openAlerts: number;
|
||||
escalatedCustomers: number;
|
||||
upgradeCandidates: number;
|
||||
downgradeCandidates: number;
|
||||
totalAmount: number;
|
||||
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
|
||||
/** Rule mix across the filtered set, most-triggered first. */
|
||||
byRule: { code: string; label: string; count: number }[];
|
||||
byStatus: { status: ReviewStatus; label: string; count: number }[];
|
||||
}
|
||||
|
||||
function inRange(date: string, range: DateRange): boolean {
|
||||
return date >= range.from && date <= range.to;
|
||||
}
|
||||
|
||||
function inr(n: number): string {
|
||||
return `₹${Math.round(n).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out what the rating ought to be once the size of the activity is taken
|
||||
* into account, and why.
|
||||
*
|
||||
* The rules give an upper bound — what the behaviour could mean at worst. The
|
||||
* amounts then pull that bound down: a customer sitting at High who has only put
|
||||
* a handful of small transfers through has not behaved like a High-risk customer
|
||||
* this period, and the review should say so. Two things stop the pull-down: a
|
||||
* case already referred for an STR, and rules whose seriousness has nothing to
|
||||
* do with value — a sanctions match or a structuring pattern stays grave at any
|
||||
* amount.
|
||||
*/
|
||||
function recommendRating(
|
||||
impliedRank: number,
|
||||
currentRisk: RiskLevel | null,
|
||||
largestAmount: number,
|
||||
totalAmount: number,
|
||||
escalated: boolean,
|
||||
ruleCodes: string[]
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||
// only where nothing weightier holds it up.
|
||||
let risk: RiskLevel;
|
||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
||||
if (escalated) {
|
||||
risk = 'HIGH';
|
||||
basis = 'escalated';
|
||||
} else if (grave) {
|
||||
risk = implied;
|
||||
basis = 'grave';
|
||||
} else if (material) {
|
||||
risk = implied;
|
||||
basis = 'material';
|
||||
} else {
|
||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||
basis = small ? 'small' : 'moderate';
|
||||
}
|
||||
|
||||
// The wording is worked out from the conclusion, not the branch, so a customer
|
||||
// being brought down is never told the activity supports where they are.
|
||||
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
|
||||
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
|
||||
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
|
||||
|
||||
if (basis === 'escalated') {
|
||||
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
|
||||
}
|
||||
|
||||
if (delta < 0) {
|
||||
if (basis === 'small') {
|
||||
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
|
||||
}
|
||||
if (basis === 'moderate') {
|
||||
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
|
||||
}
|
||||
// Material or grave, yet still below where the customer sits: the size of
|
||||
// the activity is not the reason, the rules that fired are.
|
||||
return {
|
||||
risk,
|
||||
reason:
|
||||
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
|
||||
`${rated}.`
|
||||
};
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
|
||||
}
|
||||
|
||||
if (basis === 'grave') {
|
||||
return {
|
||||
risk,
|
||||
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
|
||||
};
|
||||
}
|
||||
|
||||
return { risk, reason: '' };
|
||||
}
|
||||
|
||||
/** Applies every filter and joins each alert to its rule. */
|
||||
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
|
||||
return alerts
|
||||
.filter((a) => {
|
||||
if (!inRange(a.alertDate, filter.range)) {
|
||||
return false;
|
||||
}
|
||||
// A live alert carries no branch; hiding it under a branch filter would
|
||||
// silently drop real compliance data, so it stays visible.
|
||||
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
|
||||
return false;
|
||||
}
|
||||
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.status && a.status !== filter.status) {
|
||||
return false;
|
||||
}
|
||||
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
|
||||
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls the alerts up per customer. Sorted so the cases needing attention —
|
||||
* escalated, then rated below what the rules point at, then most alerts —
|
||||
* come to the top of the worklist.
|
||||
*/
|
||||
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
if (list) {
|
||||
list.push(v);
|
||||
} else {
|
||||
byClient.set(v.clientId, [v]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: RiskCustomerRow[] = [];
|
||||
for (const [
|
||||
clientId,
|
||||
alerts
|
||||
] of byClient) {
|
||||
const dates = alerts.map((a) => a.alertDate).sort();
|
||||
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
|
||||
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
|
||||
const head = alerts[0];
|
||||
|
||||
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
|
||||
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
|
||||
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||
|
||||
const recommendation = recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
|
||||
// Where the customer carries no rating there is nothing to move, so an
|
||||
// unrated customer is never counted as an upgrade or a downgrade.
|
||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
customerName: head.customerName,
|
||||
// Take the first non-blank — live alerts leave these empty.
|
||||
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
|
||||
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
|
||||
currentRisk,
|
||||
alerts,
|
||||
alertCount: alerts.length,
|
||||
totalAmount,
|
||||
largestAmount,
|
||||
proposedRisk,
|
||||
recommendedRisk: recommendation.risk,
|
||||
direction,
|
||||
directionReason: recommendation.reason,
|
||||
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
|
||||
firstAlertDate: dates[0],
|
||||
lastAlertDate: dates[dates.length - 1],
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
});
|
||||
}
|
||||
|
||||
// Escalations first, then ratings that need moving up, then down, then volume.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(b.escalated) - Number(a.escalated) ||
|
||||
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
|
||||
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
|
||||
b.alertCount - a.alertCount ||
|
||||
b.totalAmount - a.totalAmount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the alerts to customers whose rating should move a given way.
|
||||
*
|
||||
* Direction is a property of the customer, not of a single alert, so it can only
|
||||
* be applied once the rollup has run — the alert and transaction views are then
|
||||
* narrowed to the same customers so all three views agree.
|
||||
*/
|
||||
export function applyDirectionFilter(
|
||||
views: RiskAlertView[],
|
||||
customers: RiskCustomerRow[],
|
||||
direction: RatingDirection | ''
|
||||
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
|
||||
if (!direction) {
|
||||
return { views, customers };
|
||||
}
|
||||
const keep = customers.filter((c) => c.direction === direction);
|
||||
const ids = new Set(keep.map((c) => c.clientId));
|
||||
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens every alert onto its transactions, newest entry first, so each row
|
||||
* names the rule that transaction tripped.
|
||||
*/
|
||||
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
|
||||
const rows: RiskTxnView[] = [];
|
||||
|
||||
for (const v of views) {
|
||||
v.txns.forEach((t, i) => {
|
||||
rows.push({
|
||||
...t,
|
||||
alertId: v.alertId,
|
||||
alertDate: v.alertDate,
|
||||
clientId: v.clientId,
|
||||
customerName: v.customerName,
|
||||
accountNo: v.accountNo,
|
||||
branchCode: v.branchCode,
|
||||
currentRisk: v.currentRisk,
|
||||
status: v.status,
|
||||
remarks: v.remarks,
|
||||
rule: v.rule,
|
||||
legIndex: i + 1,
|
||||
legCount: v.txns.length
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
|
||||
}
|
||||
|
||||
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
|
||||
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
|
||||
for (const c of customers) {
|
||||
byRisk[c.currentRisk ?? 'UNRATED']++;
|
||||
}
|
||||
|
||||
const ruleCounts = new Map<string, { label: string; count: number }>();
|
||||
for (const v of views) {
|
||||
const entry = ruleCounts.get(v.ruleCode);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
} else {
|
||||
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const statusCounts = new Map<ReviewStatus, number>();
|
||||
for (const v of views) {
|
||||
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customers.length,
|
||||
alerts: views.length,
|
||||
txns: views.reduce((s, v) => s + v.txns.length, 0),
|
||||
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
|
||||
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
|
||||
escalatedCustomers: customers.filter((c) => c.escalated).length,
|
||||
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
|
||||
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
|
||||
totalAmount: views.reduce((s, v) => s + v.amount, 0),
|
||||
byRisk,
|
||||
byRule: [...ruleCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
code,
|
||||
v
|
||||
]) => ({ code, label: v.label, count: v.count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
byStatus: [...statusCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
status,
|
||||
count
|
||||
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything the screen renders, computed in one pass. */
|
||||
export interface RiskReviewModel {
|
||||
alerts: RiskAlertView[];
|
||||
customers: RiskCustomerRow[];
|
||||
txns: RiskTxnView[];
|
||||
summary: RiskReviewSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for the screen. Filtering, rollup, direction narrowing and
|
||||
* the summary all run once per filter change rather than once per template
|
||||
* binding, which matters because every view reads from the same result.
|
||||
*/
|
||||
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
customers: narrowed.customers,
|
||||
txns: riskTxnRows(narrowed.views),
|
||||
summary: riskReviewSummary(narrowed.views, narrowed.customers)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Period helpers (shared with the other period-filtered reports) ───────────
|
||||
|
||||
/** Monday to Saturday containing the given date. */
|
||||
export function weekRange(anyDateInWeek: string): DateRange {
|
||||
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
|
||||
const dow = d.getUTCDay();
|
||||
const backToMonday = dow === 0 ? 6 : dow - 1;
|
||||
const monday = new Date(d.getTime() - backToMonday * 86400000);
|
||||
const saturday = new Date(monday.getTime() + 5 * 86400000);
|
||||
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
/** First to last day of the given yyyy-MM. */
|
||||
export function monthRange(month: string): DateRange {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
faFileUpload,
|
||||
faFileWord,
|
||||
faFillDrip,
|
||||
faGavel,
|
||||
faGlobe,
|
||||
faHandHoldingUsd,
|
||||
faHome,
|
||||
@@ -297,6 +298,7 @@ export class IconsModule {
|
||||
faFileExcel,
|
||||
faFileExport,
|
||||
faFillDrip,
|
||||
faGavel,
|
||||
faGlobe,
|
||||
faHandHoldingHand,
|
||||
faHandHoldingHeart,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface SearchSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
|
||||
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
|
||||
* that aren't all visible in the collapsed label. */
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
|
||||
* it is impractical (e.g. hundreds of accounts) — a text box that filters the option list as you
|
||||
* type, instead of a native dropdown you can only scroll through. Selection only commits on
|
||||
* click; typing without picking an option reverts the box to the last committed selection on
|
||||
* blur, so the bound ngModel value never drifts from an actual, valid option.
|
||||
*
|
||||
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-search-select',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SearchSelectComponent),
|
||||
multi: true
|
||||
}
|
||||
],
|
||||
template: `
|
||||
<div class="inv-search-select">
|
||||
<input
|
||||
#inputEl
|
||||
type="text"
|
||||
class="inv-search-select__input"
|
||||
[value]="query"
|
||||
[placeholder]="placeholder"
|
||||
[disabled]="disabled"
|
||||
(input)="onInput($event)"
|
||||
(focus)="onFocus()"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
@if (open) {
|
||||
<ul
|
||||
class="inv-search-select__list"
|
||||
[style.top.px]="listTop"
|
||||
[style.left.px]="listLeft"
|
||||
[style.width.px]="listWidth"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
>
|
||||
@for (o of filteredOptions; track o.value) {
|
||||
<li
|
||||
class="inv-search-select__option"
|
||||
[class.inv-search-select__option--active]="o.value === value"
|
||||
(click)="selectOption(o)"
|
||||
>
|
||||
{{ o.label }}
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="inv-search-select__empty">No matches</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
|
||||
* component's own template, so its look is re-declared here rather than inherited. */
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-search-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.inv-search-select__input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* position: fixed, anchored via JS to the input's own screen coords (see
|
||||
* updateListPosition()) rather than absolute inside this component — most of this app's
|
||||
* cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown
|
||||
* that extends past the card's edge. */
|
||||
.inv-search-select__list {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
|
||||
}
|
||||
.inv-search-select__option {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.inv-search-select__option:hover,
|
||||
.inv-search-select__option--active {
|
||||
background: #eef2ff;
|
||||
}
|
||||
.inv-search-select__empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SearchSelectComponent implements ControlValueAccessor {
|
||||
@Input() options: SearchSelectOption[] = [];
|
||||
@Input() placeholder = 'Search…';
|
||||
|
||||
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
|
||||
|
||||
value = '';
|
||||
query = '';
|
||||
open = false;
|
||||
disabled = false;
|
||||
|
||||
listTop = 0;
|
||||
listLeft = 0;
|
||||
listWidth = 0;
|
||||
|
||||
private onChangeFn: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
get filteredOptions(): SearchSelectOption[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return this.options;
|
||||
}
|
||||
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private labelFor(value: string): string {
|
||||
return this.options.find((o) => o.value === value)?.label ?? '';
|
||||
}
|
||||
|
||||
writeValue(v: string): void {
|
||||
this.value = v ?? '';
|
||||
this.query = this.labelFor(this.value);
|
||||
}
|
||||
|
||||
registerOnChange(fn: (v: string) => void): void {
|
||||
this.onChangeFn = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
/** Anchors the fixed-position list to the input's current on-screen box. */
|
||||
private updateListPosition(): void {
|
||||
const el = this.inputEl?.nativeElement;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
this.listTop = rect.bottom + 4;
|
||||
this.listLeft = rect.left;
|
||||
this.listWidth = rect.width;
|
||||
}
|
||||
|
||||
@HostListener('window:scroll')
|
||||
@HostListener('window:resize')
|
||||
onWindowChange(): void {
|
||||
if (this.open) {
|
||||
this.updateListPosition();
|
||||
}
|
||||
}
|
||||
|
||||
onInput(event: Event): void {
|
||||
this.query = (event.target as HTMLInputElement).value;
|
||||
this.open = true;
|
||||
this.updateListPosition();
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
this.open = true;
|
||||
this.updateListPosition();
|
||||
}
|
||||
|
||||
onBlur(): void {
|
||||
this.open = false;
|
||||
this.onTouched();
|
||||
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
|
||||
this.query = this.labelFor(this.value);
|
||||
}
|
||||
|
||||
selectOption(o: SearchSelectOption): void {
|
||||
this.value = o.value;
|
||||
this.query = o.label;
|
||||
this.open = false;
|
||||
this.onChangeFn(this.value);
|
||||
this.onTouched();
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,11 @@
|
||||
</div>
|
||||
|
||||
<div class="table-filters">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic">
|
||||
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<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">
|
||||
@@ -33,7 +33,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<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">
|
||||
@@ -42,7 +42,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
@@ -53,7 +53,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (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()" />
|
||||
|
||||
@@ -5,37 +5,43 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 1.5rem;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin: 1.5rem auto;
|
||||
max-width: 1400px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.table-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 12px;
|
||||
|
||||
.table-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
color: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@@ -45,22 +51,19 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-hover);
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-main);
|
||||
opacity: 0.8;
|
||||
background: #1353a0;
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0.6;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
@@ -75,53 +78,156 @@
|
||||
}
|
||||
|
||||
.table-count {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.2rem 0.65rem;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pill-styled filter bar */
|
||||
.table-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-form-field {
|
||||
width: 240px;
|
||||
margin-bottom: -1.25em;
|
||||
.table-search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-filter {
|
||||
flex: 0 1 150px;
|
||||
min-width: 130px;
|
||||
max-width: 170px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Override Material Form Field Heights & Subscript Spacing */
|
||||
::ng-deep {
|
||||
.mat-mdc-form-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-flex {
|
||||
height: 38px !important;
|
||||
background: #fff !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding: 6px 0 !important;
|
||||
min-height: 38px !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading {
|
||||
border-radius: 8px 0 0 8px !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__trailing {
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-value,
|
||||
.mat-mdc-select-placeholder,
|
||||
input {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
[mat-icon-button] {
|
||||
color: #64748b;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
.table-loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
tr.mat-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
::ng-deep {
|
||||
.mat-mdc-header-row {
|
||||
background: #f8fafc;
|
||||
min-height: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
.mat-mdc-header-cell {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mat-mdc-row {
|
||||
min-height: 42px;
|
||||
height: 42px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:nth-child(even) {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #0f172a;
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user