feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
- CTS clearing module: inward batches (validate/pass/return), outward presentment capture, positive pay, settlement, reconciliation - Outward Clearing merged into one screen: Pending Cheques + Batches tabs (a real clearing session per NPCI submission, either the 2-hourly job or manual send), drill into a batch to settle/dishonour its cheques - Date-range, text, and quick (Today/Yesterday/All) filters with pagination across inward batches, outward presentments, outward batches, and batch detail - NACH clearing module (mandates, batches, transaction decisioning) - Shell/sidenav wiring for both new modules Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed2a40a8bf
commit
6d205c0ed2
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import lightGallery from 'lightgallery';
|
||||
import lgZoom from 'lightgallery/plugins/zoom';
|
||||
import lgFullscreen from 'lightgallery/plugins/fullscreen';
|
||||
import type { LightGallery } from 'lightgallery/lightgallery';
|
||||
import type { GalleryItem } from 'lightgallery/lg-utils';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { CtsClearingService } from '../ctsclearing.service';
|
||||
import { ClearingService } from '../../clearing-network/clearing.service';
|
||||
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
|
||||
|
||||
/**
|
||||
* One outward clearing session ("batch") — every cheque sent to NPCI sharing this batchReference. Settle /
|
||||
* dishonour and image viewing work exactly as on the Outward Clearing screen; this view is scoped to a
|
||||
* single session instead of showing every cheque ever captured.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-outward-batch-detail',
|
||||
templateUrl: './outward-batch-detail.component.html',
|
||||
styleUrls: ['./outward-batch-detail.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule
|
||||
]
|
||||
})
|
||||
export class OutwardBatchDetailComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private ctsService = inject(CtsClearingService);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private clearingService = inject(ClearingService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
|
||||
private lightboxInstance: LightGallery | null = null;
|
||||
private readonly lightboxPlugins = [
|
||||
lgZoom,
|
||||
lgFullscreen
|
||||
];
|
||||
|
||||
pillClass = pillClass;
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
batchReference!: string;
|
||||
presentments: any[] = [];
|
||||
busy = false;
|
||||
dishonourForId: number | null = null;
|
||||
shownEnvelopeId: number | null = null;
|
||||
reasonCodes: any[] = [];
|
||||
|
||||
totalRecords = 0;
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
statusFilter = '';
|
||||
|
||||
dishonourForm: UntypedFormGroup;
|
||||
filterForm: UntypedFormGroup;
|
||||
|
||||
columns = [
|
||||
'id',
|
||||
'chequeNumber',
|
||||
'drawerBank',
|
||||
'payeeName',
|
||||
'amount',
|
||||
'image',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.dishonourForm = this.formBuilder.group({
|
||||
dishonourReasonCode: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
this.filterForm = this.formBuilder.group({
|
||||
chequeNumber: [''],
|
||||
drawerBankName: [''],
|
||||
payeeName: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.batchReference = this.route.snapshot.paramMap.get('id') as string;
|
||||
this.loadAll();
|
||||
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
|
||||
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.reasonCodes = list.filter((c: any) => c.active !== false);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyLightbox();
|
||||
}
|
||||
|
||||
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
|
||||
imageUrls: { [key: string]: string } = {};
|
||||
|
||||
loadAll(): void {
|
||||
const f = this.filterForm.value;
|
||||
const filters = {
|
||||
batchReference: this.batchReference,
|
||||
chequeNumber: f.chequeNumber || undefined,
|
||||
drawerBankName: f.drawerBankName || undefined,
|
||||
payeeName: f.payeeName || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardPresentments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
|
||||
this.presentments.forEach((p: any) => {
|
||||
this.loadItemImage(p, 'front');
|
||||
this.loadItemImage(p, 'back');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterForm.reset({ chequeNumber: '', drawerBankName: '', payeeName: '' });
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
|
||||
loadItemImage(presentment: any, side: 'front' | 'back'): void {
|
||||
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
|
||||
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
|
||||
error: () => {
|
||||
/* no image uploaded for this side yet */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
|
||||
return this.imageUrls[`${presentment.id}_${side}`] || null;
|
||||
}
|
||||
|
||||
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
|
||||
openImageZoom(presentment: any, side: 'front' | 'back'): void {
|
||||
const frontUrl = this.getItemImageUrl(presentment, 'front');
|
||||
const backUrl = this.getItemImageUrl(presentment, 'back');
|
||||
const items: GalleryItem[] = [];
|
||||
if (frontUrl) {
|
||||
items.push({
|
||||
src: frontUrl,
|
||||
thumb: frontUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (backUrl) {
|
||||
items.push({
|
||||
src: backUrl,
|
||||
thumb: backUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const startIndex = side === 'back' && frontUrl ? 1 : 0;
|
||||
this.destroyLightbox();
|
||||
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||
dynamic: true,
|
||||
dynamicEl: items,
|
||||
plugins: this.lightboxPlugins,
|
||||
download: false,
|
||||
closable: true,
|
||||
escKey: true,
|
||||
zoomFromOrigin: true
|
||||
});
|
||||
this.lightboxInstance.openGallery(startIndex);
|
||||
}
|
||||
|
||||
private destroyLightbox(): void {
|
||||
if (this.lightboxInstance) {
|
||||
this.lightboxInstance.destroy();
|
||||
this.lightboxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
act(presentment: any, command: string): void {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const messages: any = {
|
||||
settle: 'Presentment settled',
|
||||
dishonour: 'Presentment dishonoured'
|
||||
};
|
||||
this.ctsService.actOnOutwardPresentment(presentment.id, command, { locale: 'en' }).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.notify(messages[command] || 'Done');
|
||||
this.loadAll();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Action failed'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDishonour(presentment: any): void {
|
||||
this.dishonourForId = this.dishonourForId === presentment.id ? null : presentment.id;
|
||||
this.dishonourForm.reset();
|
||||
}
|
||||
|
||||
submitDishonour(id: number): void {
|
||||
if (this.dishonourForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const payload = { dishonourReasonCode: this.dishonourForm.value.dishonourReasonCode, locale: 'en' };
|
||||
this.ctsService.actOnOutwardPresentment(id, 'dishonour', payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.dishonourForId = null;
|
||||
this.notify('Presentment dishonoured');
|
||||
this.loadAll();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Dishonour failed'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleEnvelope(presentment: any): void {
|
||||
this.shownEnvelopeId = this.shownEnvelopeId === presentment.id ? null : presentment.id;
|
||||
}
|
||||
|
||||
prettyJson(value: string): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user