diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html new file mode 100644 index 000000000..d4db3b8f8 --- /dev/null +++ b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.html @@ -0,0 +1,70 @@ + + +

+ + {{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }} +

+ + +
+
+ #{{ data.journalEntryId }} +
+
+ {{ data.transactionId }} +
+
+ {{ data.transactionDate }} +
+
+ ₹{{ data.amount | number: '1.2-2' }} +
+
+ {{ data.glAccountName }} +
+
+ + @if (isEscalation) { +

+ + 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. +

+ } @else { +

+ Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched. +

+ } + + +
+ + + + + diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss new file mode 100644 index 000000000..d1b2b000f --- /dev/null +++ b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.scss @@ -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; + } + } +} diff --git a/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts new file mode 100644 index 000000000..207f501da --- /dev/null +++ b/src/app/accounting/search-journal-entry/mark-entry-dialog.component.ts @@ -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); + data = inject(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()); + } + } +} diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html index f8dacebff..477b1c91c 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html @@ -535,6 +535,14 @@ + @if (markError) { +
+ + {{ markError }} + +
+ } +
@@ -692,6 +700,50 @@ + + + AML Review + + @if (markOf(e); as mark) { + + + {{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }} + + } @else if (isMarking(e)) { + Saving… + } @else if (canMark(e)) { + + + + + } @else { + — + } + + + diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss index edc754dd0..d140a94ba 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.scss +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.scss @@ -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; diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 083205d7a..78839ffff 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -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(); + /** Entry ids with a request in flight, so a row cannot be double-submitted. */ + private marking = new Set(); + 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() { diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index ceb0993c8..4d4c7febb 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -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 { + return this.http.get('/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 { + return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes }); + } + // ═══ STR cases (Principal Officer) ═══ getStrCases(): Observable { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 97de74f71..d9ffa3c20 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260802', - 'hash': '0ca6842b' + 'version': '260803', + 'hash': 'b43ed877' }, 'allow_switching_backend_instance': true };