feat(ctsclearing): validation comparison popup, decided-item read-only view, and dialog-close fixes
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Adds a "Comparison Details" popup on the inward decision dialog showing
exactly what each automatic rule compared (amount figures vs words,
instrument date vs its valid-until window, balance vs amount, RBI
Positive Pay threshold, IQA sub-checks) instead of just the pass/fail
pill, mirroring InwardClearingValidationService.java's actual logic.
For an already-decided item, the stale/postdated "as of" date now shows
the decision date rather than today's live date.

Once a cheque is decided, the batch table's Validation column collapses
to a single outcome pill instead of permanently showing all 7 rule
badges, and the item-decision dialog itself switches to a read-only
decision summary (outcome, decisioned-on, note, settlement status)
instead of a re-openable Pass/Return form — the "Decide" action becomes
"View" for decided rows.

Also fixes two silently-broken directives: matTooltip and mat-dialog-close
were used throughout the decision dialogs without MatTooltipModule /
MatDialogClose being imported into the standalone components, so every
tooltip and every close button did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-22 18:29:18 +05:30
co-authored by Claude Sonnet 5
parent 0207cf85a3
commit a22f8e7e45
8 changed files with 799 additions and 73 deletions
@@ -160,24 +160,31 @@
<ng-container matColumnDef="checks">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Validation' | translate }}</th>
<td mat-cell *matCellDef="let it">
<div class="check-badges">
@for (rule of rules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(it, rule)" [title]="rule.label">{{ rule.label }}</span>
@if (it.decision) {
<span class="pill" [ngClass]="hasFailedChecks(it) ? 'bad' : 'ok'">
{{ hasFailedChecks(it) ? 'Some checks failed' : 'All checks passed' }}
</span>
<div class="muted">See View → Comparison Details for the full breakdown</div>
} @else {
<div class="check-badges">
@for (rule of rules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(it, rule)" [title]="rule.label">{{ rule.label }}</span>
}
</div>
@if (it.validationDetail) {
<div class="validation-detail muted">{{ it.validationDetail }}</div>
}
@if (it.iqaStatus && it.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(it.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ it.iqaStatus }}</span
>
}
@if (it.iqaDetail) {
<div class="muted">{{ it.iqaDetail }}</div>
}
@if (it.iqaSubChecks) {
<div class="muted">{{ it.iqaSubChecks }}</div>
}
</div>
@if (it.validationDetail) {
<div class="validation-detail muted">{{ it.validationDetail }}</div>
}
@if (it.iqaStatus && it.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(it.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ it.iqaStatus }}</span
>
}
@if (it.iqaDetail) {
<div class="muted">{{ it.iqaDetail }}</div>
}
@if (it.iqaSubChecks) {
<div class="muted">{{ it.iqaSubChecks }}</div>
}
</td>
</ng-container>
@@ -192,6 +199,17 @@
@if (it.returnReasonCode) {
<div class="muted">{{ it.returnReasonCode }}</div>
}
@if (it.decisionedOn) {
<div class="muted">{{ it.decisionedOn | dateFormat }}</div>
}
@if (it.decisionReason) {
<div class="muted">{{ it.decisionReason }}</div>
}
@if (it.settlementBatchId) {
<div class="muted">Settlement #{{ it.settlementBatchId }}</div>
} @else if (it.decision) {
<div class="muted">Awaiting settlement</div>
}
</td>
</ng-container>
<ng-container matColumnDef="hold">
@@ -225,7 +243,9 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let it">
<button mat-button color="primary" (click)="decide(it)">{{ 'labels.buttons.Decide' | translate }}</button>
<button mat-button color="primary" (click)="decide(it)">
{{ (it.decision ? 'labels.buttons.View' : 'labels.buttons.Decide') | translate }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
@@ -11,6 +11,7 @@ import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@an
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -42,6 +43,7 @@ import { pillClass, clearingError } from '../../clearing-network/clearing-status
MatButtonModule,
MatTableModule,
MatSnackBarModule,
MatTooltipModule,
FaIconComponent
]
})
@@ -200,6 +202,13 @@ export class InwardBatchDetailComponent implements OnInit, OnDestroy {
return good ? 'ok' : 'bad';
}
/** Once a cheque is decided, the clerk doesn't need the full rule-by-rule checklist sitting in the table
* row forever — a one-line outcome is enough, with the full breakdown one click away via View -> Comparison
* Details. Kept for PENDING items, where the full checklist is exactly what's needed to decide. */
hasFailedChecks(item: any): boolean {
return this.rules.some((rule) => this.ruleClass(item, rule) === 'bad') || item?.iqaStatus === 'FAIL';
}
runValidation(): void {
if (this.busy) {
return;
@@ -22,14 +22,34 @@
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
><span>{{ item.accountNo || item.accountId || '–' }}</span>
</div>
@if (item.clientName) {
<div>
<span class="k">Account Holder</span><span>{{ item.clientName }}</span>
</div>
}
@if (accountInfo?.availableBalance !== null && accountInfo?.availableBalance !== undefined) {
<div>
<span class="k">Available Balance</span>
<span>
{{ accountInfo!.availableBalance }}
@if (balanceSufficient !== null) {
<span class="pill" [ngClass]="balanceSufficient ? 'ok' : 'bad'">
{{ balanceSufficient ? 'Sufficient' : 'Insufficient' }}
</span>
}
</span>
</div>
}
@if (
accountInfo?.onHoldAmount !== null && accountInfo?.onHoldAmount !== undefined && accountInfo!.onHoldAmount !== 0
) {
<div>
<span class="k">Existing Holds on Account</span><span>{{ accountInfo!.onHoldAmount }}</span>
</div>
}
<div>
<span class="k">Hold Placed</span><span>{{ item.holdPlaced ? (item.holdAmount ?? item.amount) : '–' }}</span>
</div>
@if (accountInfo?.availableBalance !== null && accountInfo?.availableBalance !== undefined) {
<div>
<span class="k">Available Balance</span><span>{{ accountInfo!.availableBalance }}</span>
</div>
}
</div>
<h3 class="section-title">Cheque Image</h3>
@@ -142,12 +162,73 @@
}
</div>
<h3 class="section-title">Automatic Validation</h3>
<h3 class="section-title">Instrument Details</h3>
<div class="info-grid">
@if (item.micrCode) {
<div>
<span class="k">MICR Code</span><span>{{ item.micrCode }}</span>
</div>
}
@if (item.ifsc) {
<div>
<span class="k">IFSC</span><span>{{ item.ifsc }}</span>
</div>
}
@if (item.instrumentDate) {
<div>
<span class="k">Instrument Date</span><span>{{ item.instrumentDate | dateFormat }}</span>
</div>
}
@if (item.amountInWords) {
<div>
<span class="k">Amount in Words</span><span>{{ item.amountInWords }}</span>
</div>
}
@if (item.drawerAccountNumberMasked) {
<div>
<span class="k">Drawer A/C (as on cheque)</span><span>{{ item.drawerAccountNumberMasked }}</span>
</div>
}
@if (item.draweeBankName) {
<div>
<span class="k">Drawee Bank</span><span>{{ item.draweeBankName }}</span>
</div>
}
@if (item.instrumentTypeDescription || item.instrumentType) {
<div>
<span class="k">Instrument Type</span><span>{{ item.instrumentTypeDescription || item.instrumentType }}</span>
</div>
}
@if (item.ctsReferenceNumber) {
<div>
<span class="k">CTS Reference</span><span>{{ item.ctsReferenceNumber }}</span>
</div>
}
</div>
<div class="section-header">
<h3 class="section-title">Automatic Validation</h3>
<button type="button" mat-stroked-button (click)="openValidationDetail()">Comparison Details</button>
</div>
@if (item.instrumentStopPaymentFlag) {
<div class="gate-warning">Stop payment was flagged on this instrument by the depositing bank.</div>
}
<div class="check-badges">
@for (rule of validationRules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(rule)" [title]="rule.label">{{ rule.label }}</span>
}
@if (item.iqaStatus && item.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(item.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ item.iqaStatus }}</span
>
}
</div>
@if (item.iqaDetail) {
<div class="muted">{{ item.iqaDetail }}</div>
}
@if (item.iqaSubChecks) {
<div class="muted">{{ item.iqaSubChecks }}</div>
}
@if (item.validationDetail) {
<div class="validation-detail muted">{{ item.validationDetail }}</div>
}
@@ -202,59 +283,105 @@
}
}
<div class="mode-toggle">
<button mat-stroked-button type="button" [class.active]="mode === 'PASS'" color="primary" (click)="setMode('PASS')">
{{ 'labels.buttons.Pass' | translate }}
</button>
<button
mat-stroked-button
type="button"
[class.active]="mode === 'RETURN'"
color="warn"
(click)="setMode('RETURN')"
>
{{ 'labels.buttons.Return' | translate }}
</button>
</div>
@if (mode === 'PASS') {
<form [formGroup]="passForm">
@if (!signatureVerified) {
<div class="gate-warning">Signature verification required above before this cheque can be passed.</div>
@if (item.decision) {
<h3 class="section-title">Decision</h3>
<div class="decided-summary" [ngClass]="pillClass(item.decision)">
<div class="decided-headline">
<span class="pill" [ngClass]="pillClass(item.decision)">{{ item.decision }}</span>
@if (item.decisionedOn) {
<span class="muted">on {{ item.decisionedOn | dateFormat }}</span>
}
</div>
@if (item.returnReasonCode) {
<div class="compare-row">
<span class="compare-label">Return Reason Code</span
><span class="compare-value">{{ item.returnReasonCode }}</span>
</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
</form>
@if (item.decisionReason) {
<div class="compare-row">
<span class="compare-label">Note</span><span class="compare-value">{{ item.decisionReason }}</span>
</div>
}
@if (item.settlementBatchId) {
<div class="compare-row">
<span class="compare-label">Settlement Batch</span
><span class="compare-value">#{{ item.settlementBatchId }}</span>
</div>
} @else if (item.decision === 'PASS') {
<div class="compare-row">
<span class="compare-label">Settlement</span><span class="compare-value">Awaiting settlement</span>
</div>
}
<div class="muted">
This cheque has already been decided and cannot be decided again — this view is read-only.
</div>
</div>
} @else {
<form [formGroup]="returnForm">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Return Reason Code' | translate }}</mat-label>
<mat-select formControlName="returnReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" />
</mat-form-field>
</form>
<div class="mode-toggle">
<button
mat-stroked-button
type="button"
[class.active]="mode === 'PASS'"
color="primary"
(click)="setMode('PASS')"
>
{{ 'labels.buttons.Pass' | translate }}
</button>
<button
mat-stroked-button
type="button"
[class.active]="mode === 'RETURN'"
color="warn"
(click)="setMode('RETURN')"
>
{{ 'labels.buttons.Return' | translate }}
</button>
</div>
@if (mode === 'PASS') {
<form [formGroup]="passForm">
@if (!signatureVerified) {
<div class="gate-warning">Signature verification required above before this cheque can be passed.</div>
}
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
</form>
} @else {
<form [formGroup]="returnForm">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Return Reason Code' | translate }}</mat-label>
<mat-select formControlName="returnReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" />
</mat-form-field>
</form>
}
}
<div #lightboxRoot style="display: none"></div>
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button type="button" mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
[color]="mode === 'RETURN' ? 'warn' : 'primary'"
(click)="submit()"
[disabled]="busy || (mode === 'RETURN' ? returnForm.invalid : !signatureVerified)"
>
{{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }}
<button mat-raised-button type="button" mat-dialog-close>
{{ (item.decision ? 'labels.buttons.Close' : 'labels.buttons.Cancel') | translate }}
</button>
@if (!item.decision) {
<button
mat-raised-button
[color]="mode === 'RETURN' ? 'warn' : 'primary'"
(click)="submit()"
[disabled]="busy || (mode === 'RETURN' ? returnForm.invalid : !signatureVerified)"
>
{{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }}
</button>
}
</div>
@@ -24,6 +24,11 @@
color: rgb(0 0 0 / 50%);
margin-bottom: 2px;
}
.pill {
margin-left: 6px;
vertical-align: middle;
}
}
.mode-toggle {
@@ -36,6 +41,54 @@
}
}
.decided-summary {
padding: 14px 16px;
border-radius: 8px;
border-left: 4px solid rgb(0 0 0 / 30%);
background: rgb(0 0 0 / 4%);
&.ok {
border-left-color: #1a9c63;
background: #e8f8f0;
}
&.bad {
border-left-color: #d32f2f;
background: #fdeaea;
}
}
.decided-headline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.compare-row {
display: grid;
grid-template-columns: minmax(120px, 38%) 1fr;
gap: 6px 16px;
padding: 4px 0;
border-top: 1px solid rgb(255 255 255 / 60%);
&:first-of-type {
border-top: none;
}
}
.compare-label {
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.compare-value {
font-size: 0.85rem;
word-break: break-word;
}
mat-form-field {
width: 100%;
}
@@ -45,6 +98,17 @@ mat-form-field {
font-size: 0.95rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.section-title {
margin: 20px 0 8px;
}
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.82rem;
@@ -10,14 +10,17 @@
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { FormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialog,
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -31,7 +34,8 @@ import type { GalleryItem } from 'lightgallery/lg-utils';
import { CtsClearingService } from '../../ctsclearing.service';
import { ClearingService } from '../../../clearing-network/clearing.service';
import { SignatoryService, AccountSignatoryInfo, JointHolder } from '../../../clearing-network/signatory.service';
import { clearingError } from '../../../clearing-network/clearing-status';
import { clearingError, pillClass } from '../../../clearing-network/clearing-status';
import { ValidationDetailDialogComponent } from './validation-detail-dialog/validation-detail-dialog.component';
interface RequiredSigner {
clientId: number;
@@ -72,8 +76,10 @@ const OVERLAY_DEFAULT_WIDTH = 160;
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButtonModule,
MatCheckboxModule
MatCheckboxModule,
MatTooltipModule
]
})
export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
@@ -84,6 +90,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
private clearingService = inject(ClearingService);
private signatoryService = inject(SignatoryService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
@ViewChild('chequeStage') chequeStageEl?: ElementRef<HTMLElement>;
@@ -165,6 +172,9 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.destroyLightbox();
}
/** Shared status-string -> pill-color mapping, same one the batch list table already uses for iqaStatus/decision. */
pillClass = pillClass;
/** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */
ruleClass(rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' {
const value = this.item?.[rule.key];
@@ -175,6 +185,17 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
return good ? 'ok' : 'bad';
}
/** What each automatic rule actually compared (amount vs words, instrument date vs the valid-until window,
* balance vs amount, etc.) instead of just the pass/fail pill — opened on demand, not always-on clutter. */
openValidationDetail(): void {
this.dialog.open(ValidationDetailDialogComponent, {
data: { item: this.item, accountInfo: this.accountInfo },
width: '560px',
maxWidth: '95vw',
maxHeight: '85vh'
});
}
private loadImages(): void {
this.ctsService.getInwardItemImage(this.item.id, 'front').subscribe({
next: (url: string) => (this.frontImageUrl = url),
@@ -558,6 +579,22 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
};
}
/** null when the account's balance couldn't be resolved at all — the automatic "Balance" check pill already
* covers that outcome, this just makes the actual sufficiency legible at a glance rather than requiring
* the clerk to mentally subtract Available Balance minus Amount themselves. */
get balanceSufficient(): boolean | null {
const available = this.accountInfo?.availableBalance;
if (
available === null ||
available === undefined ||
this.item?.amount === null ||
this.item?.amount === undefined
) {
return null;
}
return available >= this.item.amount;
}
get modeOfOperationLabel(): string {
const labels: { [key: string]: string } = {
EITHER_OR_SURVIVOR: 'Either or Survivor — any one signatory',
@@ -0,0 +1,199 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dialog-header">
<div>
<h2 mat-dialog-title>Validation Comparison</h2>
<div class="dialog-subtitle">Cheque {{ item.chequeNumber }} · what each automatic check actually compared</div>
</div>
<button type="button" mat-icon-button mat-dialog-close matTooltip="Close" class="header-close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content>
<div class="rule-card" [ngClass]="ruleClass(item.accountCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.accountCheckPassed))"></fa-icon>
<span>Account</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Account Type</span><span class="compare-value">{{ item.accountType || '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Account Number</span
><span class="compare-value">{{ item.accountNo || item.accountId || '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Account Holder</span><span class="compare-value">{{ item.clientName || '–' }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.balanceCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.balanceCheckPassed))"></fa-icon>
<span>Balance</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Available Balance</span
><span class="compare-value">{{ accountInfo?.availableBalance ?? 'Not available' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Amount</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Result</span
><span class="compare-value">{{
balanceSufficient === null ? 'Unknown' : balanceSufficient ? 'Sufficient' : 'Insufficient'
}}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.duplicateCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.duplicateCheckPassed))"></fa-icon>
<span>Duplicate</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Checked Against</span
><span class="compare-value"
>Same cheque number + account + amount already PASSed, and the cheque leaf's own USED status</span
>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Number</span
><span class="compare-value">{{ item.chequeNumber || '–' }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.stopPaymentCheckPassed)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.stopPaymentCheckPassed))"></fa-icon>
<span>Stop Payment</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Checked Against</span
><span class="compare-value">The matching cheque leaf's own stop-payment status</span>
</div>
@if (item.instrumentStopPaymentFlag) {
<div class="compare-row">
<span class="compare-label">Depositing-Bank Flag</span
><span class="compare-value warn-text"
>Stop payment was flagged on this instrument by the depositing bank</span
>
</div>
}
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.amountWordsFiguresMatch)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.amountWordsFiguresMatch))"></fa-icon>
<span>Amount Match</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Amount (Figures)</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Amount (Words)</span
><span class="compare-value">{{ item.amountInWords || 'Not captured' }}</span>
</div>
@if (amountWordsDigits) {
<div class="compare-row">
<span class="compare-label">Digits Extracted From Words</span
><span class="compare-value">{{ amountWordsDigits }}</span>
</div>
}
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.staleOrPostdated, false)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.staleOrPostdated, false))"></fa-icon>
<span>Stale / Postdated</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">Instrument Date</span
><span class="compare-value">{{ item.instrumentDate ? (item.instrumentDate | dateFormat) : '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Valid Until (+3 months)</span
><span class="compare-value">{{ instrumentValidUntil ? (instrumentValidUntil | dateFormat) : '–' }}</span>
</div>
<div class="compare-row">
<span class="compare-label">{{ asOfLabel }}</span
><span class="compare-value">{{ asOfDate | dateFormat }}</span>
</div>
</div>
</div>
<div class="rule-card" [ngClass]="ruleClass(item.positivePayCheck)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(ruleClass(item.positivePayCheck))"></fa-icon>
<span>Positive Pay</span>
</div>
<div class="compare-list">
<div class="compare-row">
<span class="compare-label">RBI PPS Threshold</span
><span class="compare-value">{{ positivePayThreshold }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Cheque Amount</span><span class="compare-value">{{ item.amount }}</span>
</div>
<div class="compare-row">
<span class="compare-label">Applicable</span
><span class="compare-value">{{
positivePayApplicable ? 'Yes — at or above threshold' : 'No — below threshold'
}}</span>
</div>
</div>
</div>
@if (item.iqaStatus && item.iqaStatus !== 'NA') {
<div class="rule-card" [ngClass]="pillClass(item.iqaStatus)">
<div class="rule-card-header">
<fa-icon [icon]="statusIcon(pillClass(item.iqaStatus))"></fa-icon>
<span>Image Quality — {{ item.iqaStatus }}</span>
</div>
@if (item.iqaDetail || item.iqaSubChecks) {
<div class="compare-list">
@if (item.iqaDetail) {
<div class="compare-row">
<span class="compare-label">Detail</span><span class="compare-value">{{ item.iqaDetail }}</span>
</div>
}
@if (item.iqaSubChecks) {
<div class="compare-row">
<span class="compare-label">Sub-checks</span><span class="compare-value">{{ item.iqaSubChecks }}</span>
</div>
}
</div>
}
</div>
}
@if (item.validationDetail) {
<div class="backend-notes">
<strong>Backend Validation Notes</strong>
<p>{{ item.validationDetail }}</p>
</div>
}
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</div>
@@ -0,0 +1,144 @@
/**
* 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/.
*/
$ok: #1a9c63;
$ok-bg: #e8f8f0;
$bad: #d32f2f;
$bad-bg: #fdeaea;
$neutral: #6b6b6b;
$neutral-bg: #f0f0f0;
.dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 12px 4px 24px;
[mat-dialog-title] {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
}
}
.dialog-subtitle {
margin-top: 2px;
color: rgb(0 0 0 / 55%);
font-size: 0.8rem;
}
.header-close {
flex-shrink: 0;
color: rgb(0 0 0 / 45%);
}
.rule-card {
margin-bottom: 12px;
padding: 14px 16px;
border-radius: 8px;
border-left: 4px solid $neutral;
background: $neutral-bg;
&:last-of-type {
margin-bottom: 0;
}
&.ok {
border-left-color: $ok;
background: $ok-bg;
.rule-card-header {
color: $ok;
}
}
&.bad {
border-left-color: $bad;
background: $bad-bg;
.rule-card-header {
color: $bad;
}
}
&.neutral {
.rule-card-header {
color: $neutral;
}
}
}
.rule-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 0.9rem;
font-weight: 600;
fa-icon {
font-size: 0.95rem;
}
}
.compare-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.compare-row {
display: grid;
grid-template-columns: minmax(120px, 38%) 1fr;
gap: 6px 16px;
padding: 4px 0;
border-top: 1px solid rgb(255 255 255 / 60%);
&:first-child {
border-top: none;
}
}
.compare-label {
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.compare-value {
font-size: 0.85rem;
word-break: break-word;
&.warn-text {
color: $bad;
font-weight: 600;
}
}
.backend-notes {
margin-top: 4px;
padding: 10px 14px;
border-radius: 8px;
background: rgb(0 0 0 / 4%);
font-size: 0.85rem;
strong {
display: block;
margin-bottom: 4px;
color: rgb(0 0 0 / 55%);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
p {
margin: 0;
}
}
@@ -0,0 +1,126 @@
/**
* 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 {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { pillClass } from '../../../../clearing-network/clearing-status';
import { AccountSignatoryInfo } from '../../../../clearing-network/signatory.service';
/** RBI Positive Pay System threshold — mirrors InwardClearingValidationService.POSITIVE_PAY_THRESHOLD. */
const POSITIVE_PAY_THRESHOLD = 50000;
/** Stale-instrument window — mirrors InwardClearingValidationService.STALE_MONTHS. */
const STALE_MONTHS = 3;
/**
* Shows exactly what each automatic validation rule actually compared, not just its pass/fail outcome —
* e.g. the amount in figures next to the amount in words, or the instrument date next to its valid-until
* date — mirroring the real values InwardClearingValidationService.java checks server-side.
*/
@Component({
selector: 'mifosx-validation-detail-dialog',
templateUrl: './validation-detail-dialog.component.html',
styleUrls: ['./validation-detail-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButtonModule,
MatTooltipModule,
FaIconComponent
]
})
export class ValidationDetailDialogComponent {
dialogRef = inject<MatDialogRef<ValidationDetailDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
item: any = this.data?.item || {};
accountInfo: AccountSignatoryInfo | null = this.data?.accountInfo || null;
pillClass = pillClass;
ruleClass(value: boolean | null | undefined, goodWhenTrue = true): 'ok' | 'bad' | 'neutral' {
if (value === null || value === undefined) {
return 'neutral';
}
const good = goodWhenTrue ? value === true : value === false;
return good ? 'ok' : 'bad';
}
statusIcon(status: 'ok' | 'bad' | 'neutral'): string {
return status === 'ok' ? 'check-circle' : status === 'bad' ? 'times-circle' : 'minus-circle';
}
get balanceSufficient(): boolean | null {
const available = this.accountInfo?.availableBalance;
if (
available === null ||
available === undefined ||
this.item?.amount === null ||
this.item?.amount === undefined
) {
return null;
}
return available >= this.item.amount;
}
/** The digits InwardClearingValidationService.amountWordsFiguresMatch() extracts from amountInWords, for a
* transparent side-by-side with the figure amount rather than just trusting the pass/fail pill. */
get amountWordsDigits(): string | null {
const words: string | undefined = this.item?.amountInWords;
if (!words) {
return null;
}
const digits = words.replace(/[^0-9]/g, '');
return digits || null;
}
get instrumentValidUntil(): Date | null {
if (!this.item?.instrumentDate) {
return null;
}
const d = new Date(this.item.instrumentDate);
d.setMonth(d.getMonth() + STALE_MONTHS);
return d;
}
/**
* The date the stale/postdated check actually ran against — for an already-decided item that's when it was
* decided (decisionedOn), NOT today. Showing today's live date here for a cheque passed 50 days ago would
* make it look like the comparison is being silently re-run against the current date, which it isn't — the
* decision already happened, using whatever "today" was back then.
*/
get asOfDate(): Date {
return this.item?.decisionedOn ? new Date(this.item.decisionedOn) : new Date();
}
get asOfLabel(): string {
return this.item?.decisionedOn ? 'As Of Decision' : 'Today';
}
get positivePayApplicable(): boolean {
return (this.item?.amount ?? 0) >= POSITIVE_PAY_THRESHOLD;
}
readonly positivePayThreshold = POSITIVE_PAY_THRESHOLD;
}