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

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; } } 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[] = []; 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); } }); } }