Compare commits
19
Commits
8dd9767bdf
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
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[]> {
|
||||
|
||||
@@ -20,195 +20,143 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pill-style tab navigation -->
|
||||
<nav mat-tab-nav-bar class="ac-tabs" [tabPanel]="tabPanel">
|
||||
<!-- Pill-style tab navigation — wraps onto multiple rows so every section stays visible and
|
||||
clickable at once, instead of being hidden behind prev/next scroll arrows. -->
|
||||
<nav class="ac-tabs">
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./checker-inbox']"
|
||||
routerLinkActive
|
||||
#checkerInbox="routerLinkActive"
|
||||
[active]="checkerInbox.isActive"
|
||||
routerLink="./checker-inbox"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
||||
<span>Checker Inbox</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./client-approval']"
|
||||
routerLinkActive
|
||||
#clientApproval="routerLinkActive"
|
||||
[active]="clientApproval.isActive"
|
||||
routerLink="./client-approval"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'READ_CLIENT'"
|
||||
>
|
||||
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
||||
<span>Client Approval</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./loan-approval']"
|
||||
routerLinkActive
|
||||
#loanApproval="routerLinkActive"
|
||||
[active]="loanApproval.isActive"
|
||||
*mifosxHasPermission="'READ_LOAN'"
|
||||
>
|
||||
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||
<span>Loan Approval</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./loan-disbursal']"
|
||||
routerLinkActive
|
||||
#loanDisbursal="routerLinkActive"
|
||||
[active]="loanDisbursal.isActive"
|
||||
routerLink="./loan-disbursal"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'READ_LOAN'"
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Loan Disbursal</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./reschedule-loan']"
|
||||
routerLinkActive
|
||||
#rescheduleLoan="routerLinkActive"
|
||||
[active]="rescheduleLoan.isActive"
|
||||
routerLink="./reschedule-loan"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'RESCHEDULE_LOAN'"
|
||||
>
|
||||
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
||||
<span>Reschedule Loan</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-account']"
|
||||
routerLinkActive
|
||||
#pendingAccount="routerLinkActive"
|
||||
[active]="pendingAccount.isActive"
|
||||
routerLink="./pending-account"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Account</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-savings-products']"
|
||||
routerLinkActive
|
||||
#pendingSavingsProducts="routerLinkActive"
|
||||
[active]="pendingSavingsProducts.isActive"
|
||||
routerLink="./pending-savings-products"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Product Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-savings-account-edits']"
|
||||
routerLinkActive
|
||||
#pendingSavingsAccountEdits="routerLinkActive"
|
||||
[active]="pendingSavingsAccountEdits.isActive"
|
||||
routerLink="./pending-savings-account-edits"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-share-account-edits']"
|
||||
routerLinkActive
|
||||
#pendingShareAccountEdits="routerLinkActive"
|
||||
[active]="pendingShareAccountEdits.isActive"
|
||||
routerLink="./pending-share-account-edits"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Share Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-client-edits']"
|
||||
routerLinkActive
|
||||
#pendingClientEdits="routerLinkActive"
|
||||
[active]="pendingClientEdits.isActive"
|
||||
routerLink="./pending-client-edits"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Client Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./rejected-account']"
|
||||
routerLinkActive
|
||||
#rejectedAccount="routerLinkActive"
|
||||
[active]="rejectedAccount.isActive"
|
||||
routerLink="./rejected-account"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||
<span>Reject Account</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./terminated-account']"
|
||||
routerLinkActive
|
||||
#terminatedAccount="routerLinkActive"
|
||||
[active]="terminatedAccount.isActive"
|
||||
routerLink="./terminated-account"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
|
||||
<span>Terminated Account</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./withdrawal-requests']"
|
||||
routerLinkActive
|
||||
#withdrawalRequests="routerLinkActive"
|
||||
[active]="withdrawalRequests.isActive"
|
||||
routerLink="./withdrawal-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Withdrawal Requests</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./deposit-requests']"
|
||||
routerLinkActive
|
||||
#depositRequests="routerLinkActive"
|
||||
[active]="depositRequests.isActive"
|
||||
routerLink="./deposit-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||
<span>Deposit Requests</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./disbursement-requests']"
|
||||
routerLinkActive
|
||||
#disbursementRequests="routerLinkActive"
|
||||
[active]="disbursementRequests.isActive"
|
||||
routerLink="./disbursement-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Disbursement & Transfers</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./chequebook-requests']"
|
||||
routerLinkActive
|
||||
#chequebookRequests="routerLinkActive"
|
||||
[active]="chequebookRequests.isActive"
|
||||
routerLink="./chequebook-requests"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
|
||||
>
|
||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||
@@ -216,7 +164,7 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<mat-tab-nav-panel #tabPanel>
|
||||
<div class="ac-tab-panel">
|
||||
<router-outlet></router-outlet>
|
||||
</mat-tab-nav-panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,10 @@ $amber: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Pill-style tab navigation ───────── */
|
||||
/* ───────── Pill-style tab navigation ─────────
|
||||
A plain wrapping flex row, not mat-tab-nav-bar — with 16 sections, Material's single-row strip
|
||||
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
|
||||
click away instead of a scroll-and-hunt. */
|
||||
.ac-tabs {
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
@@ -71,88 +74,51 @@ $amber: #f59e0b;
|
||||
border-radius: 14px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-link-container {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-links {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Material draws the active-tab underline indicator with a 2px height —
|
||||
hide it; our pill background already signals the active tab. */
|
||||
.mdc-tab-indicator__content--underline,
|
||||
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ac-tab {
|
||||
display: inline-flex !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 18px !important;
|
||||
border-radius: 10px !important;
|
||||
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
color: #64748b !important;
|
||||
background: transparent !important;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
font-family: Inter, 'DM Sans', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent !important;
|
||||
opacity: 1 !important;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&__ic {
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover:not(.mdc-tab--active) {
|
||||
background: #f1f5f9 !important;
|
||||
color: $navy !important;
|
||||
&:hover:not(.ac-tab--active) {
|
||||
background: #f1f5f9;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
/* Material 3 adds `.mdc-tab--active` (and `.mat-mdc-tab-link-active`) to the
|
||||
selected link. Both target the same pill state — bold blue gradient. */
|
||||
&.mdc-tab--active,
|
||||
&.mat-mdc-tab-link-active {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
|
||||
color: #fff !important;
|
||||
&.ac-tab--active {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
|
||||
border-color: $blue-d !important;
|
||||
|
||||
/* Material 3 wraps the label in its own span with a color rule that
|
||||
beats the parent. Force white on every descendant so the active-tab
|
||||
label is always legible against the dark gradient pill. */
|
||||
::ng-deep .mdc-tab__text-label,
|
||||
::ng-deep .mat-mdc-tab-link-content,
|
||||
::ng-deep span,
|
||||
.ac-tab__ic {
|
||||
color: #fff !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
border-color: $blue-d;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.ac-tabs {
|
||||
overflow-x: auto;
|
||||
|
||||
::ng-deep .mat-mdc-tab-links {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ac-tab {
|
||||
padding: 0 14px !important;
|
||||
font-size: 12px !important;
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component } from '@angular/core';
|
||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||
import { RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Approval Center — checker inbox + client/loan approval workflows.
|
||||
*
|
||||
* The tab strip is a plain wrapping nav (not mat-tab-nav-bar) on purpose: with 16 sections,
|
||||
* Material's single-row tab strip hides most of them behind prev/next pagination arrows, forcing
|
||||
* a click-and-scan hunt for whichever one you need. Wrapping onto multiple rows keeps every tab
|
||||
* visible and one click away.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-checker-inbox-and-tasks',
|
||||
@@ -22,10 +26,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./checker-inbox-and-tasks.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabNav,
|
||||
MatTabLink,
|
||||
RouterLinkActive,
|
||||
MatTabNavPanel,
|
||||
RouterOutlet,
|
||||
FaIconComponent
|
||||
]
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260802',
|
||||
'hash': '0ca6842b'
|
||||
'version': '260803',
|
||||
'hash': 'b43ed877'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user