Files
cbsnexora/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.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

290 lines
8.8 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 { 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;
}
}
}