/** * 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; 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 "_" — 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: `

Front — Cheque ${presentment.chequeNumber}

` }); } if (backUrl) { items.push({ src: backUrl, thumb: backUrl, subHtml: `

Back — Cheque ${presentment.chequeNumber}

` }); } 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; } } }