Files
cbsnexora/src/app/ctsclearing/outward-presentment/outward-presentment.component.ts
T
Jaanu423andClaude Sonnet 5 6d205c0ed2
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
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>
2026-07-21 15:57:45 +05:30

538 lines
18 KiB
TypeScript

/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Observable, concat } from 'rxjs';
import { Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
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 { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Outward CTS clearing — landing page for the whole outward flow. Capture a cheque, see it sit as
* "Pending" until a clearing session sends it, then find it as a row in the Batches table below (a real
* session, one row per shared batchReference) — click a batch to see its cheques and settle/dishonour them.
* There's no per-cheque "Send" any more: cheques only leave via the 2-hourly session job or the manual
* "Send Pending Batch" fallback, so batch is always the unit you browse sent cheques by.
*/
@Component({
selector: 'mifosx-outward-presentment',
templateUrl: './outward-presentment.component.html',
styleUrls: ['./outward-presentment.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatTabsModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class OutwardPresentmentComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private router = inject(Router);
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 });
}
busy = false;
showCaptureForm = false;
/** Cheque images picked in the capture form itself — uploaded right after creation, in the same Submit. */
selectedFrontFile: File | null = null;
selectedBackFile: File | null = null;
frontPreviewUrl: string | null = null;
backPreviewUrl: string | null = null;
captureForm: UntypedFormGroup;
pendingFilterForm: UntypedFormGroup;
batchesFilterForm: UntypedFormGroup;
// ----- Pending cheques (CAPTURED, not yet in any batch) -----
presentments: any[] = [];
pendingTotalRecords = 0;
pendingPageSize = 10;
pendingPageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
pendingColumns = [
'id',
'chequeNumber',
'drawerBank',
'payeeName',
'amount',
'depositedDate',
'image',
'status'
];
// ----- Batches (real, already-sent clearing sessions) -----
batches: any[] = [];
batchesTotalRecords = 0;
batchesPageSize = 10;
batchesPageIndex = 0;
batchColumns = [
'batchReference',
'session',
'items',
'amount',
'progress',
'actions'
];
/**
* Defaults to today on first load so this isn't every session ever sent — "All" widens it. Persisted on
* the singleton service so opening a batch and navigating back doesn't silently reset the choice.
*/
get batchDateFilter(): 'today' | 'all' {
return this.ctsService.outwardBatchesDateFilter;
}
constructor() {
this.captureForm = this.formBuilder.group({
depositingAccountType: [
'SAVINGS',
Validators.required
],
depositingAccountId: [
'',
Validators.required
],
chequeNumber: [
'',
Validators.required
],
amount: [
'',
Validators.required
],
payeeName: [''],
drawerBankName: [''],
drawerBankMicr: [''],
drawerIfsc: [''],
depositedDate: [
'',
Validators.required
],
stopPaymentFlag: [false],
iqaStatus: ['PASS'],
iqaDetail: [''],
drawerAccountNumber: ['']
});
this.pendingFilterForm = this.formBuilder.group({
fromDate: [''],
toDate: [''],
chequeNumber: [''],
drawerBankName: [''],
payeeName: ['']
});
// Batches defaults to today on first load — matches the persisted Today/All quick toggle below.
const initialBatchDate = this.batchDateFilter === 'today' ? new Date() : null;
this.batchesFilterForm = this.formBuilder.group({
fromDate: [initialBatchDate],
toDate: [initialBatchDate],
batchReference: ['']
});
}
ngOnInit(): void {
this.loadPresentments();
this.loadBatches();
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
imageUrls: { [key: string]: string } = {};
loadPresentments(): void {
const f = this.pendingFilterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
chequeNumber: f.chequeNumber || undefined,
drawerBankName: f.drawerBankName || undefined,
payeeName: f.payeeName || undefined
};
this.ctsService
.getOutwardPresentments('CAPTURED', this.pendingPageIndex * this.pendingPageSize, this.pendingPageSize, filters)
.subscribe((res: any) => {
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.pendingTotalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
this.presentments.forEach((p: any) => {
this.loadItemImage(p, 'front');
this.loadItemImage(p, 'back');
});
});
}
applyPendingFilters(): void {
this.pendingPageIndex = 0;
this.loadPresentments();
}
clearPendingFilters(): void {
this.pendingFilterForm.reset({ fromDate: '', toDate: '', chequeNumber: '', drawerBankName: '', payeeName: '' });
this.pendingPageIndex = 0;
this.loadPresentments();
}
onPendingPageChange(event: PageEvent): void {
this.pendingPageSize = event.pageSize;
this.pendingPageIndex = event.pageIndex;
this.loadPresentments();
}
loadBatches(): void {
const f = this.batchesFilterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
batchReference: f.batchReference || undefined
};
this.ctsService
.getOutwardBatches(this.batchesPageIndex * this.batchesPageSize, this.batchesPageSize, filters)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.batchesTotalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
applyBatchesFilters(): void {
// A manual date edit no longer matches a plain "today"/"all" quick state — treat it as custom.
this.ctsService.outwardBatchesDateFilter = 'all';
this.batchesPageIndex = 0;
this.loadBatches();
}
clearBatchesFilters(): void {
this.ctsService.outwardBatchesDateFilter = 'all';
this.batchesFilterForm.reset({ fromDate: '', toDate: '', batchReference: '' });
this.batchesPageIndex = 0;
this.loadBatches();
}
setBatchDateFilter(filter: 'today' | 'all'): void {
this.ctsService.outwardBatchesDateFilter = filter;
const day = filter === 'today' ? new Date() : null;
this.batchesFilterForm.patchValue({ fromDate: day, toDate: day });
this.batchesPageIndex = 0;
this.loadBatches();
}
onBatchesPageChange(event: PageEvent): void {
this.batchesPageSize = event.pageSize;
this.batchesPageIndex = event.pageIndex;
this.loadBatches();
}
openBatch(batch: any): void {
this.router.navigate([
'/ctsclearing',
'outward-batches',
batch.batchReference
]);
}
/** 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;
}
}
uploadItemImage(presentment: any, side: 'front' | 'back', event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) {
return;
}
this.ctsService.uploadOutwardPresentmentImage(presentment.id, side, file).subscribe({
next: () => {
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
this.loadItemImage(presentment, side);
},
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
});
input.value = '';
}
private toApiDate(value: any): string | undefined {
if (!value) {
return undefined;
}
return formatDate(value, 'yyyy-MM-dd', 'en');
}
/** Picks a front/back cheque image right in the capture form — uploaded once the cheque is created below. */
onCaptureFileSelected(side: 'front' | 'back', event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files && input.files[0];
if (!file) {
return;
}
const previewUrl = URL.createObjectURL(file);
if (side === 'front') {
if (this.frontPreviewUrl) {
URL.revokeObjectURL(this.frontPreviewUrl);
}
this.selectedFrontFile = file;
this.frontPreviewUrl = previewUrl;
} else {
if (this.backPreviewUrl) {
URL.revokeObjectURL(this.backPreviewUrl);
}
this.selectedBackFile = file;
this.backPreviewUrl = previewUrl;
}
}
private resetCaptureFiles(): void {
if (this.frontPreviewUrl) {
URL.revokeObjectURL(this.frontPreviewUrl);
}
if (this.backPreviewUrl) {
URL.revokeObjectURL(this.backPreviewUrl);
}
this.selectedFrontFile = null;
this.selectedBackFile = null;
this.frontPreviewUrl = null;
this.backPreviewUrl = null;
}
/** Front + back cheque image are mandatory before a cheque can be captured — checked in the template too. */
canSubmitCapture(): boolean {
return this.captureForm.valid && !!this.selectedFrontFile && !!this.selectedBackFile && !this.busy;
}
submitCapture(): void {
if (!this.canSubmitCapture()) {
if (this.captureForm.valid && (!this.selectedFrontFile || !this.selectedBackFile)) {
this.notify('Front and back cheque image are both required', true);
}
return;
}
this.busy = true;
const raw = this.captureForm.value;
const payload = {
depositingAccountType: raw.depositingAccountType,
depositingAccountId: raw.depositingAccountId,
chequeNumber: raw.chequeNumber,
amount: raw.amount,
payeeName: raw.payeeName || undefined,
drawerBankName: raw.drawerBankName || undefined,
drawerBankMicr: raw.drawerBankMicr || undefined,
drawerIfsc: raw.drawerIfsc || undefined,
depositedDate: this.toApiDate(raw.depositedDate),
stopPaymentFlag: !!raw.stopPaymentFlag,
iqaStatus: raw.iqaStatus || undefined,
iqaDetail: raw.iqaDetail || undefined,
drawerAccountNumber: raw.drawerAccountNumber || undefined,
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.createOutwardPresentment(payload).subscribe({
next: (res: any) => {
const newId = res?.resourceId;
const front = this.selectedFrontFile;
const back = this.selectedBackFile;
if (!newId || (!front && !back)) {
this.finishCapture('Cheque captured');
return;
}
// upload whichever image(s) were picked, in the same Submit action, before closing the form.
// Sequential (concat), NOT parallel (forkJoin): two concurrent updates to the same row would race
// against its @Version optimistic lock and throw OptimisticLockException on the second write.
const uploads: Observable<any>[] = [];
if (front) {
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'front', front));
}
if (back) {
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'back', back));
}
concat(...uploads).subscribe({
complete: () => this.finishCapture('Cheque captured with image(s)'),
error: (err: any) => {
this.busy = false;
this.notify(
clearingError(err, 'Cheque captured, but image upload failed — upload it from the table row'),
true
);
this.showCaptureForm = false;
this.resetCaptureFiles();
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
this.loadPresentments();
}
});
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not capture the cheque'), true);
}
});
}
cancelCapture(): void {
this.showCaptureForm = false;
this.resetCaptureFiles();
}
private finishCapture(message: string): void {
this.busy = false;
this.showCaptureForm = false;
this.resetCaptureFiles();
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
this.notify(message);
this.loadPresentments();
}
/** Fills the capture form with realistic sample data in one click, for quick demo/testing. */
fillDummyData(): void {
const stamp = Date.now().toString().slice(-6);
const banks = [
{ name: 'IDFC First Bank', micr: '400229015', ifsc: 'IDFC0000045' },
{ name: 'HDFC Bank', micr: '400240012', ifsc: 'HDFC0000078' },
{ name: 'ICICI Bank', micr: '400229033', ifsc: 'ICIC0000112' }
];
const bank = banks[Math.floor(Math.random() * banks.length)];
const today = new Date();
this.captureForm.patchValue({
depositingAccountType: 'SAVINGS',
depositingAccountId: 1,
chequeNumber: stamp,
amount: (Math.floor(Math.random() * 900) + 100) * 100,
payeeName: 'Test Payee ' + stamp,
drawerBankName: bank.name,
drawerBankMicr: bank.micr,
drawerIfsc: bank.ifsc,
drawerAccountNumber: 'XXXXXXXX' + stamp,
depositedDate: today,
stopPaymentFlag: false,
iqaStatus: 'PASS',
iqaDetail: 'Auto-filled sample data'
});
this.notify('Dummy values filled — review and Submit');
}
/**
* Manual stand-in for the clearing-session scheduler (currently disabled): bundles every CAPTURED cheque
* into one CTS batch file and sends it to the clearing house in a single go.
*/
sendPendingBatch(): void {
if (this.busy) {
return;
}
this.busy = true;
this.ctsService.sendPendingOutwardBatch().subscribe({
next: (res: any) => {
this.busy = false;
const count = res?.resourceId ?? '';
this.notify(count ? `Batch sent to the clearing house — ${count} cheque(s)` : 'No pending cheques to send');
this.loadPresentments();
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not send the pending batch'), true);
}
});
}
}