feat(accounting): add AML manual referral and marking to general ledger entries
This commit is contained in:
@@ -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>
|
||||||
</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">
|
<div class="gl-table-shell">
|
||||||
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
||||||
<!-- #ID -->
|
<!-- #ID -->
|
||||||
@@ -692,6 +700,50 @@
|
|||||||
</mat-cell>
|
</mat-cell>
|
||||||
</ng-container>
|
</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 -->
|
<!-- Actions -->
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
|
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
|
||||||
|
|||||||
@@ -1328,6 +1328,17 @@
|
|||||||
flex: 0 0 130px;
|
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 {
|
::ng-deep .mat-column-actions {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
min-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 ───────────────────────────────────────────────────────────────
|
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||||
.gl-empty {
|
.gl-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
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';
|
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({
|
@Component({
|
||||||
selector: 'mifosx-search-journal-entry',
|
selector: 'mifosx-search-journal-entry',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private amlService = inject(AmlService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
'manualEntry',
|
'manualEntry',
|
||||||
'createdByUserName',
|
'createdByUserName',
|
||||||
'submittedOnDate',
|
'submittedOnDate',
|
||||||
|
'amlMark',
|
||||||
'actions'
|
'actions'
|
||||||
];
|
];
|
||||||
dayWiseColumns = [
|
dayWiseColumns = [
|
||||||
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
dayWiseData: any[] = [];
|
dayWiseData: any[] = [];
|
||||||
glAccountInfo: any = null;
|
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 ──────────────────────────────────────────────────────────────
|
// ── UI state ──────────────────────────────────────────────────────────────
|
||||||
activeQuickFilter = '';
|
activeQuickFilter = '';
|
||||||
activeFiltersCount = 0;
|
activeFiltersCount = 0;
|
||||||
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
this.setFilteredOffices();
|
this.setFilteredOffices();
|
||||||
this.getJournalEntries();
|
this.getJournalEntries();
|
||||||
this.loadLedgerSummary();
|
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() {
|
ngOnDestroy() {
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ export class AmlService {
|
|||||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
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) ═══
|
// ═══ STR cases (Principal Officer) ═══
|
||||||
|
|
||||||
getStrCases(): Observable<any[]> {
|
getStrCases(): Observable<any[]> {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260802',
|
'version': '260803',
|
||||||
'hash': '0ca6842b'
|
'hash': 'b43ed877'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user