feat(ctsclearing): validation comparison popup, decided-item read-only view, and dialog-close fixes
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:
co-authored by
Claude Sonnet 5
parent
0207cf85a3
commit
a22f8e7e45
@@ -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;
|
||||
|
||||
+179
-52
@@ -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>
|
||||
|
||||
+64
@@ -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;
|
||||
|
||||
+40
-3
@@ -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',
|
||||
|
||||
+199
@@ -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>
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+126
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user