diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4fac8d9f9..e10b19872 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,6 +37,8 @@ import { GroupsModule } from './groups/groups.module'; import { CentersModule } from './centers/centers.module'; import { AccountingModule } from './accounting/accounting.module'; import { ChequeBookModule } from './chequebook/chequebook.module'; +import { CtsClearingModule } from './ctsclearing/ctsclearing.module'; +import { NachClearingModule } from './nachclearing/nachclearing.module'; import { SystemModule } from './system/system.module'; import { ProductsModule } from './products/products.module'; import { OrganizationModule } from './organization/organization.module'; @@ -144,6 +146,8 @@ export function HttpLoaderFactory(http: HttpClient) { CentersModule, AccountingModule, ChequeBookModule, + CtsClearingModule, + NachClearingModule, SystemModule, ProductsModule, OrganizationModule, diff --git a/src/app/clearing-network/clearing-status.ts b/src/app/clearing-network/clearing-status.ts new file mode 100644 index 000000000..f1ae27125 --- /dev/null +++ b/src/app/clearing-network/clearing-status.ts @@ -0,0 +1,54 @@ +/** + * 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/. + */ + +/** + * Maps a clearing status/decision string to one of the three shared .pill classes + * (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the + * colour language stays consistent across the whole clearing feature. + */ +export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' { + const s = (status || '').toUpperCase(); + const ok = [ + 'PASSED', + 'PASS', + 'POSTED', + 'MATCHED', + 'SETTLED', + 'SENT', + 'SENT_TO_CLEARING_HOUSE', + 'PROCESSED', + 'VALIDATED', + 'ACTIVE', + 'CLEARED', + 'HONOURED', + 'COMPLETED' + ]; + const bad = [ + 'RETURNED', + 'RETURN', + 'REJECTED', + 'MISMATCH', + 'DISHONOURED', + 'CANCELLED', + 'FAILED', + 'FAIL', + 'UNROUTABLE' + ]; + if (ok.includes(s)) { + return 'ok'; + } + if (bad.includes(s)) { + return 'bad'; + } + return 'neutral'; +} + +/** Extracts the most specific backend error message from a Fineract-style error response. */ +export function clearingError(err: any, fallback = 'Request failed'): string { + return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback; +} diff --git a/src/app/clearing-network/clearing.service.ts b/src/app/clearing-network/clearing.service.ts new file mode 100644 index 000000000..340e33ad9 --- /dev/null +++ b/src/app/clearing-network/clearing.service.ts @@ -0,0 +1,77 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting, + * and reconciliation. + */ +@Injectable({ + providedIn: 'root' +}) +export class ClearingService { + private http = inject(HttpClient); + + // ----- Return reason codes ----- + + /** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */ + getReturnReasonCodes(appliesTo: string): Observable { + return this.http.get('/clearing/return-reason-codes', { + params: new HttpParams().set('appliesTo', appliesTo) + }); + } + + // ----- Settlement batches ----- + + /** Server-side paginated — returns { pageItems, totalFilteredRecords }. */ + getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (clearingType) params = params.set('clearingType', clearingType); + if (status) params = params.set('status', status); + return this.http.get('/clearing/settlement-batches', { params }); + } + + getSettlementBatch(id: string | number): Observable { + return this.http.get(`/clearing/settlement-batches/${id}`); + } + + /** payload: { clearingType, sourceBatchId }. */ + prepareSettlement(payload: any): Observable { + return this.http.post('/clearing/settlement-batches', payload); + } + + postSettlement(id: string | number): Observable { + return this.http.post( + `/clearing/settlement-batches/${id}`, + {}, + { params: new HttpParams().set('command', 'post') } + ); + } + + rejectSettlement(id: string | number, payload: any): Observable { + return this.http.post(`/clearing/settlement-batches/${id}`, payload, { + params: new HttpParams().set('command', 'reject') + }); + } + + // ----- Reconciliations ----- + + getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (clearingType) params = params.set('clearingType', clearingType); + return this.http.get('/clearing/reconciliations', { params }); + } + + runReconciliation(payload: any): Observable { + return this.http.post('/clearing/reconciliations', payload); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index e1e4d9659..607680008 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -991,14 +991,24 @@ - - + + + + - - diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index ccc1888f7..95778bb7c 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -247,6 +247,32 @@ {{ 'labels.menus.Cheque Book Inventory' | translate }} + + + + + {{ 'labels.menus.CTS Clearing' | translate }} + + + + + + {{ 'labels.menus.NACH Clearing' | translate }} + + +
+ + +
+ @for (screen of screens; track screen.label) { + + +
+ + + + {{ screen.label | translate }} +
+

{{ screen.description | translate }}

+
+
+ } +
+
diff --git a/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.scss b/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.scss new file mode 100644 index 000000000..8a994b5c7 --- /dev/null +++ b/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.scss @@ -0,0 +1,68 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.cb-card { + cursor: pointer; + transition: + box-shadow 0.2s ease, + transform 0.2s ease; + + &:hover { + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + transform: translateY(-2px); + } +} + +.card-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} + +.card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + flex: 0 0 auto; + background: rgb(25 118 210 / 10%); + color: #1976d2; + font-size: 1.15rem; +} + +.card-title { + font-size: 1.05rem; + font-weight: 600; + line-height: 1.2; +} + +.card-desc { + color: rgb(0 0 0 / 60%); + font-size: 0.85rem; + margin: 0; +} diff --git a/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.ts b/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.ts new file mode 100644 index 000000000..ee8871603 --- /dev/null +++ b/src/app/ctsclearing/ctsclearing-dashboard/ctsclearing-dashboard.component.ts @@ -0,0 +1,73 @@ +/** + * 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 } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCardModule } from '@angular/material/card'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * CTS Clearing landing page — one card per screen (inward presentment, outward presentment, settlement, + * reconciliation) plus a shortcut to the shared clearing network directory. + */ +@Component({ + selector: 'mifosx-ctsclearing-dashboard', + templateUrl: './ctsclearing-dashboard.component.html', + styleUrls: ['./ctsclearing-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + MatIconModule, + MatCardModule, + FaIconComponent + ] +}) +export class CtsClearingDashboardComponent { + screens = [ + { + route: [ + '/ctsclearing', + 'inward' + ], + label: 'Inward Clearing', + icon: 'arrow-left', + description: 'Presentment batches drawn on us — per-cheque validation, pass/return decisioning' + }, + { + route: [ + '/ctsclearing', + 'outward' + ], + label: 'Outward Clearing', + icon: 'arrow-right', + description: + 'Cheques deposited by our customers — capture, then browse the 2-hourly clearing-session batches to settle/dishonour' + }, + { + route: [ + '/ctsclearing', + 'settlement' + ], + label: 'Settlement', + icon: 'money-check', + description: 'Net settlement batches — prepare, post to the GL, reject' + }, + { + route: [ + '/ctsclearing', + 'reconciliation' + ], + label: 'Reconciliation', + icon: 'check-circle', + description: 'Reconcile CBS totals against the clearing-house figures' + } + ]; +} diff --git a/src/app/ctsclearing/ctsclearing-routing.module.ts b/src/app/ctsclearing/ctsclearing-routing.module.ts new file mode 100644 index 000000000..3f0ea8272 --- /dev/null +++ b/src/app/ctsclearing/ctsclearing-routing.module.ts @@ -0,0 +1,81 @@ +/** + * 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +/** Routing Imports */ +import { Route } from '../core/route/route.service'; + +/** Custom Components */ +import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component'; +import { InwardBatchesComponent } from './inward-batches/inward-batches.component'; +import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component'; +import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component'; +import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component'; +import { SettlementComponent } from './settlement/settlement.component'; +import { ReconciliationComponent } from './reconciliation/reconciliation.component'; +import { PositivePayComponent } from './positive-pay/positive-pay.component'; + +/** CTS Clearing Routes */ +const routes: Routes = [ + Route.withShell([ + { + path: 'ctsclearing', + data: { title: 'CTS Clearing', breadcrumb: 'CTS Clearing' }, + children: [ + { + path: '', + component: CtsClearingDashboardComponent + }, + { + path: 'inward', + component: InwardBatchesComponent, + data: { title: 'Inward Clearing', breadcrumb: 'Inward Clearing' } + }, + { + path: 'inward/:id', + component: InwardBatchDetailComponent, + data: { title: 'Inward Batch', routeParamBreadcrumb: 'id' } + }, + { + path: 'outward', + component: OutwardPresentmentComponent, + data: { title: 'Outward Clearing', breadcrumb: 'Outward Clearing' } + }, + { + path: 'outward-batches/:id', + component: OutwardBatchDetailComponent, + data: { title: 'Outward Batch', routeParamBreadcrumb: 'id' } + }, + { + path: 'settlement', + component: SettlementComponent, + data: { title: 'Settlement', breadcrumb: 'Settlement' } + }, + { + path: 'reconciliation', + component: ReconciliationComponent, + data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' } + }, + { + path: 'positive-pay', + component: PositivePayComponent, + data: { title: 'Positive Pay', breadcrumb: 'Positive Pay' } + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class CtsClearingRoutingModule {} diff --git a/src/app/ctsclearing/ctsclearing.module.ts b/src/app/ctsclearing/ctsclearing.module.ts new file mode 100644 index 000000000..827b6fedb --- /dev/null +++ b/src/app/ctsclearing/ctsclearing.module.ts @@ -0,0 +1,53 @@ +/** + * 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 { NgModule } from '@angular/core'; + +/** Custom Modules */ +import { SharedModule } from '../shared/shared.module'; +import { DirectivesModule } from '../directives/directives.module'; +import { PipesModule } from 'app/pipes/pipes.module'; +import { CtsClearingRoutingModule } from './ctsclearing-routing.module'; + +/** Custom Components (standalone) */ +import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component'; +import { InwardBatchesComponent } from './inward-batches/inward-batches.component'; +import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component'; +import { ItemDecisionDialogComponent } from './inward-batch-detail/item-decision-dialog/item-decision-dialog.component'; +import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component'; +import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component'; +import { SettlementComponent } from './settlement/settlement.component'; +import { ReconciliationComponent } from './reconciliation/reconciliation.component'; +import { PositivePayComponent } from './positive-pay/positive-pay.component'; + +/** + * CTS (Cheque Truncation System) Clearing Module. + * + * Inward presentment batches with per-cheque validation and pass/return decisioning, outward + * presentment life-cycle (capture -> send -> settle/dishonour), plus the CTS views of the shared + * settlement and reconciliation screens. + */ +@NgModule({ + imports: [ + SharedModule, + DirectivesModule, + PipesModule, + CtsClearingRoutingModule, + CtsClearingDashboardComponent, + InwardBatchesComponent, + InwardBatchDetailComponent, + ItemDecisionDialogComponent, + OutwardPresentmentComponent, + OutwardBatchDetailComponent, + SettlementComponent, + ReconciliationComponent, + PositivePayComponent + ] +}) +export class CtsClearingModule {} diff --git a/src/app/ctsclearing/ctsclearing.service.ts b/src/app/ctsclearing/ctsclearing.service.ts new file mode 100644 index 000000000..80ab73695 --- /dev/null +++ b/src/app/ctsclearing/ctsclearing.service.ts @@ -0,0 +1,210 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * CTS (Cheque Truncation System) clearing — inward presentment batches with the per-cheque validation + * ruleset and pass/return decisioning, and the outward presentment life-cycle (capture -> send -> + * settle/dishonour). Shared settlement/reconciliation/bank/routing calls live in ClearingService. + */ +@Injectable({ + providedIn: 'root' +}) +export class CtsClearingService { + private http = inject(HttpClient); + + /** + * Persists the Outward Clearing screen's Batches Today/All filter across navigation (e.g. viewing a batch + * and coming back) — otherwise the component would reset to "today" every time it's recreated. + */ + outwardBatchesDateFilter: 'today' | 'all' = 'today'; + + /** Same persistence, for the Inward Clearing screen's Today/Yesterday/All quick filter. */ + inwardBatchesDateFilter: 'today' | 'yesterday' | 'all' = 'today'; + + // ----- Inward batches ----- + + /** Server-side paginated — returns { pageItems, totalFilteredRecords }. */ + getInwardBatches( + status?: string, + offset = 0, + limit = 20, + filters?: { fromDate?: string; toDate?: string; batchReference?: string; presentingBankName?: string } + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + if (filters?.fromDate) params = params.set('fromDate', filters.fromDate); + if (filters?.toDate) params = params.set('toDate', filters.toDate); + if (filters?.batchReference) params = params.set('batchReference', filters.batchReference); + if (filters?.presentingBankName) params = params.set('presentingBankName', filters.presentingBankName); + return this.http.get('/ctsclearing/inward-batches', { params }); + } + + getInwardBatch(id: string | number): Observable { + return this.http.get(`/ctsclearing/inward-batches/${id}`); + } + + /** Sub-list may come back as a raw array OR a page — callers handle both. */ + getInwardBatchItems(id: string | number): Observable { + return this.http.get(`/ctsclearing/inward-batches/${id}/items`); + } + + createInwardBatch(payload: any): Observable { + return this.http.post('/ctsclearing/inward-batches', payload); + } + + validateInwardBatch(id: string | number): Observable { + return this.http.post( + `/ctsclearing/inward-batches/${id}`, + {}, + { params: new HttpParams().set('command', 'validate') } + ); + } + + // ----- Inward items ----- + + getInwardItem(id: string | number): Observable { + return this.http.get(`/ctsclearing/inward-items/${id}`); + } + + passInwardItem(id: string | number, payload: any): Observable { + return this.http.post(`/ctsclearing/inward-items/${id}`, payload, { + params: new HttpParams().set('command', 'pass') + }); + } + + /** Uploads a real scanned cheque image (front|back) for an inward item. */ + uploadInwardItemImage(id: string | number, side: 'front' | 'back', file: File): Observable { + const formData = new FormData(); + formData.append('file', file, file.name); + return this.http.post(`/ctsclearing/inward-items/${id}/image`, formData, { + params: new HttpParams().set('side', side) + }); + } + + /** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */ + getInwardItemImage(id: string | number, side: 'front' | 'back'): Observable { + return this.http.get(`/ctsclearing/inward-items/${id}/image`, { + params: new HttpParams().set('side', side), + responseType: 'text' + }); + } + + returnInwardItem(id: string | number, payload: any): Observable { + return this.http.post(`/ctsclearing/inward-items/${id}`, payload, { + params: new HttpParams().set('command', 'return') + }); + } + + // ----- Outward presentments ----- + + getOutwardPresentments( + status?: string, + offset = 0, + limit = 20, + filters?: { + fromDate?: string; + toDate?: string; + chequeNumber?: string; + drawerBankName?: string; + payeeName?: string; + batchReference?: string; + } + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + if (filters?.fromDate) params = params.set('fromDate', filters.fromDate); + if (filters?.toDate) params = params.set('toDate', filters.toDate); + if (filters?.chequeNumber) params = params.set('chequeNumber', filters.chequeNumber); + if (filters?.drawerBankName) params = params.set('drawerBankName', filters.drawerBankName); + if (filters?.payeeName) params = params.set('payeeName', filters.payeeName); + if (filters?.batchReference) params = params.set('batchReference', filters.batchReference); + return this.http.get('/ctsclearing/outward-presentments', { params }); + } + + /** One row per outward clearing session ("batch") — cheques grouped by the shared batchReference they were sent under. */ + getOutwardBatches( + offset = 0, + limit = 20, + filters?: { fromDate?: string; toDate?: string; batchReference?: string } + ): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (filters?.fromDate) params = params.set('fromDate', filters.fromDate); + if (filters?.toDate) params = params.set('toDate', filters.toDate); + if (filters?.batchReference) params = params.set('batchReference', filters.batchReference); + return this.http.get('/ctsclearing/outward-presentments/batches', { params }); + } + + getOutwardPresentment(id: string | number): Observable { + return this.http.get(`/ctsclearing/outward-presentments/${id}`); + } + + createOutwardPresentment(payload: any): Observable { + return this.http.post('/ctsclearing/outward-presentments', payload); + } + + /** command: 'send' | 'settle' | 'dishonour'. Dishonour carries { dishonourReasonCode }. */ + actOnOutwardPresentment(id: string | number, command: string, payload: any = {}): Observable { + return this.http.post(`/ctsclearing/outward-presentments/${id}`, payload, { + params: new HttpParams().set('command', command) + }); + } + + /** Uploads a real scanned cheque image (front|back) for an outward presentment. */ + uploadOutwardPresentmentImage(id: string | number, side: 'front' | 'back', file: File): Observable { + const formData = new FormData(); + formData.append('file', file, file.name); + return this.http.post(`/ctsclearing/outward-presentments/${id}/image`, formData, { + params: new HttpParams().set('side', side) + }); + } + + /** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */ + getOutwardPresentmentImage(id: string | number, side: 'front' | 'back'): Observable { + return this.http.get(`/ctsclearing/outward-presentments/${id}/image`, { + params: new HttpParams().set('side', side), + responseType: 'text' + }); + } + + /** Offices/branches, for the "presenting branch" selector on the outward capture form. */ + getOffices(): Observable { + return this.http.get('/offices'); + } + + /** + * Bundles every CAPTURED outward presentment into one CTS batch file and sends it to the clearing house in + * a single go — the same action the (disabled by default) clearing-session scheduler would run automatically. + */ + sendPendingOutwardBatch(): Observable { + return this.http.post('/ctsclearing/outward-presentments/send-pending-batch', {}); + } + + // ----- Positive Pay (RBI PPS) ----- + + getPositivePayRegistrations(accountId?: string | number, status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (accountId) params = params.set('accountId', accountId); + if (status) params = params.set('status', status); + return this.http.get('/ctsclearing/positive-pay', { params }); + } + + registerPositivePay(payload: any): Observable { + return this.http.post('/ctsclearing/positive-pay', payload); + } + + cancelPositivePay(id: string | number, payload: any): Observable { + return this.http.post(`/ctsclearing/positive-pay/${id}`, payload, { + params: new HttpParams().set('command', 'cancel') + }); + } +} diff --git a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html new file mode 100644 index 000000000..ba5f1ba2f --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.html @@ -0,0 +1,225 @@ + + +
+ + + + +
+
+ {{ 'labels.inputs.Batch Reference' | translate }}{{ batch.batchReference }} +
+
+ {{ 'labels.inputs.Session Date' | translate }}{{ batch.sessionDate ? (batch.sessionDate | dateFormat) : '–' }} +
+
+ {{ 'labels.inputs.Presentment Type' | translate }}{{ batch.presentmentType }} +
+
+ {{ 'labels.inputs.Items' | translate }}{{ batch.itemCount ?? batch.totalCount ?? items.length }} +
+
+ {{ 'labels.inputs.Total Amount' | translate }}{{ batch.totalAmount ?? '–' }} +
+
+ {{ 'labels.inputs.Status' | translate }} + {{ batch.status }} +
+ @if (batch.presentingBankName || batch.presentingBankCode) { +
+ {{ 'labels.inputs.Presenting Bank' | translate }}{{ batch.presentingBankName || '–' }} {{ batch.presentingBankCode }} +
+ } + @if (batch.returnTatDeadline) { +
+ {{ 'labels.inputs.Return TAT Deadline' | translate }}{{ batch.returnTatDeadline | dateFormat }} +
+ } +
+
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Cheque Number' | translate }} + {{ it.chequeNumber }} + @if (it.instrumentStopPaymentFlag) { + STOP + } + @if (it.micrCode) { + {{ it.micrCode }} + } + @if (it.ifsc) { + {{ it.ifsc }} + } + @if (it.ctsReferenceNumber) { + {{ it.ctsReferenceNumber }} + } + @if (it.instrumentDate) { + Dated {{ it.instrumentDate | dateFormat }} + } + {{ 'labels.inputs.Account' | translate }} + {{ it.accountNo || it.accountId || '–' }} + @if (it.clientName) { + {{ it.clientName }} + } + {{ 'labels.inputs.Payee Name' | translate }}{{ it.payeeName }}{{ 'labels.inputs.Amount' | translate }} + {{ it.amount }} + @if (it.amountInWords) { +
{{ it.amountInWords }}
+ } +
{{ 'labels.inputs.Image' | translate }} +
+ @if (getItemImageUrl(it, 'front')) { + front + } @else { + + + } + @if (getItemImageUrl(it, 'back')) { + back + } @else { + + + } +
+
{{ 'labels.inputs.Validation' | translate }} +
+ @for (rule of rules; track rule.key) { + {{ rule.label }} + } +
+ @if (it.validationDetail) { +
{{ it.validationDetail }}
+ } + @if (it.iqaStatus && it.iqaStatus !== 'NA') { + IQA: {{ it.iqaStatus }} + } + @if (it.iqaDetail) { +
{{ it.iqaDetail }}
+ } +
{{ 'labels.inputs.Decision' | translate }} + @if (it.decision) { + {{ it.decision }} + } @else { + {{ it.itemStatus || 'PENDING' }} + } + @if (it.returnReasonCode) { +
{{ it.returnReasonCode }}
+ } +
{{ 'labels.inputs.Hold' | translate }} + @if (it.holdPlaced) { + {{ it.holdAmount ?? '✓' }} + } @else { + – + } + {{ 'labels.inputs.Return Deadline' | translate }} + {{ it.returnDeadline ? (it.returnDeadline | dateFormat) : '–' }} + @if (it.originalSerialNumber) { + Orig # {{ it.originalSerialNumber }} + } + @if (it.depositDate) { + Deposited {{ it.depositDate | dateFormat }} + } + @if (it.presentingBranchCode) { + Branch {{ it.presentingBranchCode }} + } + + +
+ @if (items.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } +
+ +
+
diff --git a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.scss b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.scss new file mode 100644 index 000000000..ab0a3238f --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.scss @@ -0,0 +1,127 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.info-card { + margin-bottom: 16px; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px 20px; + + > div { + display: flex; + flex-direction: column; + } + + .k { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 50%); + margin-bottom: 2px; + } +} + +.action-toolbar { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.table-wrapper { + overflow-x: auto; + background: #fff; +} + +table { + width: 100%; +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.check-badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + max-width: 320px; +} + +.validation-detail { + margin-top: 4px; + max-width: 320px; +} + +.muted { + display: block; + color: rgb(0 0 0 / 50%); + font-size: 0.78rem; + margin-left: 0; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} + +.image-cell { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.cheque-thumb { + width: 40px; + height: 28px; + object-fit: cover; + border: 1px solid #ddd; + border-radius: 3px; + cursor: pointer; +} diff --git a/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts new file mode 100644 index 000000000..8a59b9761 --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/inward-batch-detail.component.ts @@ -0,0 +1,247 @@ +/** + * 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, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +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 { ClearingService } from '../../clearing-network/clearing.service'; +import { ItemDecisionDialogComponent } from './item-decision-dialog/item-decision-dialog.component'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** + * One inward CTS batch — its header, the per-cheque items table with a badge per validation rule, and the + * batch-level "Run Validation" and "Prepare Settlement" actions. Each item can be manually PASSed or + * RETURNed through the item-decision dialog. + */ +@Component({ + selector: 'mifosx-inward-batch-detail', + templateUrl: './inward-batch-detail.component.html', + styleUrls: ['./inward-batch-detail.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + RouterLink, + MatButtonModule, + MatTableModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class InwardBatchDetailComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private ctsService = inject(CtsClearingService); + private clearingService = inject(ClearingService); + private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @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 }); + } + + batchId!: string; + batch: any = {}; + items: any[] = []; + busy = false; + + /** Per-cheque validation rules — label + the item field, and whether "true" is the good outcome. */ + readonly rules: { key: string; label: string; goodWhenTrue: boolean }[] = [ + { key: 'accountCheckPassed', label: 'Account', goodWhenTrue: true }, + { key: 'balanceCheckPassed', label: 'Balance', goodWhenTrue: true }, + { key: 'signatureOk', label: 'Signature', goodWhenTrue: true }, + { key: 'duplicateCheckPassed', label: 'Duplicate', goodWhenTrue: true }, + { key: 'stopPaymentCheckPassed', label: 'Stop Pay', goodWhenTrue: true }, + { key: 'amountWordsFiguresMatch', label: 'Amt Match', goodWhenTrue: true }, + { key: 'staleOrPostdated', label: 'Stale/Postdated', goodWhenTrue: false } + ]; + + columns = [ + 'chequeNumber', + 'drawer', + 'payeeName', + 'amount', + 'image', + 'checks', + 'decision', + 'hold', + 'returnDeadline', + 'actions' + ]; + + ngOnInit(): void { + this.batchId = this.route.snapshot.paramMap.get('id') as string; + this.loadAll(); + } + + ngOnDestroy(): void { + this.destroyLightbox(); + } + + /** Cheque image data URLs, keyed "_" — populated once fetched, absent means none uploaded (yet). */ + imageUrls: { [key: string]: string } = {}; + + loadAll(): void { + this.ctsService.getInwardBatch(this.batchId).subscribe((data: any) => (this.batch = data || {})); + this.ctsService.getInwardBatchItems(this.batchId).subscribe((data: any) => { + this.items = data?.pageItems ?? (Array.isArray(data) ? data : []); + this.items.forEach((it: any) => { + this.loadItemImage(it, 'front'); + this.loadItemImage(it, 'back'); + }); + }); + } + + /** Fetches the image as an authenticated base64 data URL and caches it for [src] binding — 404 (no upload yet) is expected and silent. */ + loadItemImage(item: any, side: 'front' | 'back'): void { + this.ctsService.getInwardItemImage(item.id, side).subscribe({ + next: (dataUrl: string) => (this.imageUrls[`${item.id}_${side}`] = dataUrl), + error: () => { + /* no image uploaded for this side yet */ + } + }); + } + + getItemImageUrl(item: any, side: 'front' | 'back'): string | null { + return this.imageUrls[`${item.id}_${side}`] || null; + } + + /** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */ + openImageZoom(item: any, side: 'front' | 'back'): void { + const frontUrl = this.getItemImageUrl(item, 'front'); + const backUrl = this.getItemImageUrl(item, 'back'); + const items: GalleryItem[] = []; + if (frontUrl) { + items.push({ + src: frontUrl, + thumb: frontUrl, + subHtml: `

Front — Cheque ${item.chequeNumber}

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

Back — Cheque ${item.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(item: 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.uploadInwardItemImage(item.id, side, file).subscribe({ + next: () => { + this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded'); + this.loadItemImage(item, side); + }, + error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true) + }); + input.value = ''; + } + + /** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */ + ruleClass(item: any, rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' { + const value = item?.[rule.key]; + if (value === null || value === undefined) { + return 'neutral'; + } + const good = rule.goodWhenTrue ? value === true : value === false; + return good ? 'ok' : 'bad'; + } + + runValidation(): void { + if (this.busy) { + return; + } + this.busy = true; + this.ctsService.validateInwardBatch(this.batchId).subscribe({ + next: () => { + this.busy = false; + this.notify('Validation run'); + this.loadAll(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Validation failed'), true); + } + }); + } + + prepareSettlement(): void { + if (this.busy) { + return; + } + this.busy = true; + this.clearingService.prepareSettlement({ clearingType: 'CTS', sourceBatchId: this.batch.id }).subscribe({ + next: () => { + this.busy = false; + this.notify('Settlement batch prepared — see Settlement'); + this.loadAll(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not prepare settlement'), true); + } + }); + } + + decide(item: any): void { + const dialogRef = this.dialog.open(ItemDecisionDialogComponent, { data: { item }, width: '480px' }); + dialogRef.afterClosed().subscribe((result: any) => { + if (result?.actioned) { + this.loadAll(); + } + }); + } +} diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html new file mode 100644 index 000000000..4b989ece5 --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.html @@ -0,0 +1,77 @@ + + +

{{ 'labels.inputs.Cheque' | translate }} {{ item.chequeNumber }}

+ +
+
+
+ {{ 'labels.inputs.Payee Name' | translate }}{{ item.payeeName || '–' }} +
+
+ {{ 'labels.inputs.Amount' | translate }}{{ item.amount }} +
+
+ {{ 'labels.inputs.Account' | translate }}{{ item.accountNo || item.accountId || '–' }} +
+
+ +
+ + +
+ + @if (mode === 'PASS') { +
+ + {{ 'labels.inputs.Note' | translate }} + + +
+ } @else { +
+ + {{ 'labels.inputs.Return Reason Code' | translate }} + + @for (c of reasonCodes; track c.id) { + {{ c.code }} — {{ c.description }} + } + + + + {{ 'labels.inputs.Reason' | translate }} + + +
+ } +
+ +
+ + +
diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss new file mode 100644 index 000000000..78cebfbef --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.scss @@ -0,0 +1,41 @@ +/** + * 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/. + */ + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px 16px; + margin-bottom: 16px; + + > div { + display: flex; + flex-direction: column; + } + + .k { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(0 0 0 / 50%); + margin-bottom: 2px; + } +} + +.mode-toggle { + display: flex; + gap: 8px; + margin-bottom: 12px; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +mat-form-field { + width: 100%; +} diff --git a/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts new file mode 100644 index 000000000..b30528940 --- /dev/null +++ b/src/app/ctsclearing/inward-batch-detail/item-decision-dialog/item-decision-dialog.component.ts @@ -0,0 +1,128 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { + MatDialogRef, + MAT_DIALOG_DATA, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { CtsClearingService } from '../../ctsclearing.service'; +import { ClearingService } from '../../../clearing-network/clearing.service'; +import { clearingError } from '../../../clearing-network/clearing-status'; + +/** + * Manual decision on a single inward CTS cheque — PASS (with an optional note) or RETURN (with a return + * reason code loaded from the CTS reason-code master, plus a free-text reason). + */ +@Component({ + selector: 'mifosx-item-decision-dialog', + templateUrl: './item-decision-dialog.component.html', + styleUrls: ['./item-decision-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatButtonModule + ] +}) +export class ItemDecisionDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private ctsService = inject(CtsClearingService); + private clearingService = inject(ClearingService); + private snackBar = inject(MatSnackBar); + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + item: any = this.data?.item || {}; + mode: 'PASS' | 'RETURN' = 'PASS'; + busy = false; + reasonCodes: any[] = []; + + passForm: UntypedFormGroup; + returnForm: UntypedFormGroup; + + constructor() { + this.passForm = this.formBuilder.group({ + note: [''] + }); + this.returnForm = this.formBuilder.group({ + returnReasonCode: [ + '', + Validators.required + ], + reason: [''] + }); + } + + ngOnInit(): void { + this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => { + const list = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.reasonCodes = list.filter((c: any) => c.active !== false); + }); + } + + setMode(mode: 'PASS' | 'RETURN'): void { + this.mode = mode; + } + + submit(): void { + if (this.busy) { + return; + } + if (this.mode === 'PASS') { + this.busy = true; + const payload = { note: this.passForm.value.note || undefined, locale: 'en' }; + this.ctsService.passInwardItem(this.item.id, payload).subscribe({ + next: () => { + this.busy = false; + this.notify('Cheque passed'); + this.dialogRef.close({ actioned: true }); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not pass the cheque'), true); + } + }); + } else { + if (this.returnForm.invalid) { + return; + } + this.busy = true; + const raw = this.returnForm.value; + const payload = { returnReasonCode: raw.returnReasonCode, reason: raw.reason || undefined, locale: 'en' }; + this.ctsService.returnInwardItem(this.item.id, payload).subscribe({ + next: () => { + this.busy = false; + this.notify('Cheque returned'); + this.dialogRef.close({ actioned: true }); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not return the cheque'), true); + } + }); + } + } +} diff --git a/src/app/ctsclearing/inward-batches/inward-batches.component.html b/src/app/ctsclearing/inward-batches/inward-batches.component.html new file mode 100644 index 000000000..e4d0df1a1 --- /dev/null +++ b/src/app/ctsclearing/inward-batches/inward-batches.component.html @@ -0,0 +1,258 @@ + + +
+ + + @if (!showReceiveForm) { +
+ @for (s of ['RECEIVED', 'VALIDATED', 'SETTLED']; track s) { + + } +
+ +
+ + + +
+ + + +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + {{ 'labels.inputs.Batch Reference' | translate }} + + + + {{ 'labels.inputs.Presenting Bank Name' | translate }} + + +
+
+ + +
+
+
+
+ } + + @if (showReceiveForm) { + + +
+
+ + {{ 'labels.inputs.Batch Reference' | translate }} + + + + {{ 'labels.inputs.Session Date' | translate }} + + + + + + {{ 'labels.inputs.Presentment Type' | translate }} + + {{ 'labels.inputs.Regular' | translate }} + {{ 'labels.inputs.Express' | translate }} + {{ 'labels.inputs.High Value' | translate }} + + + + {{ 'labels.inputs.Presenting Bank Name' | translate }} + + {{ 'labels.text.Bank that presented this batch to us' | translate }} + + + {{ 'labels.inputs.Presenting Bank Code' | translate }} + + +
+ +

{{ 'labels.inputs.Cheques' | translate }}

+
+ @for (row of items.controls; track row; let i = $index) { +
+ + {{ 'labels.inputs.Cheque Number' | translate }} + + + + {{ 'labels.inputs.MICR Code' | translate }} + + + + {{ 'labels.inputs.IFSC' | translate }} + + + + {{ 'labels.inputs.Payee Name' | translate }} + + + + {{ 'labels.inputs.Amount' | translate }} + + + + {{ 'labels.inputs.Amount In Words' | translate }} + + + + {{ 'labels.inputs.Instrument Date' | translate }} + + + + + + {{ 'labels.inputs.Presented Date' | translate }} + + + + + + {{ 'labels.inputs.Account Type' | translate }} + + {{ 'labels.inputs.Savings' | translate }} + {{ 'labels.inputs.Current' | translate }} + + + + {{ 'labels.inputs.Account Id' | translate }} + + + + {{ 'labels.inputs.Original Serial No' | translate }} + + + + {{ 'labels.inputs.Presenting Branch Code' | translate }} + + + +
+ } +
+ + +
+ + +
+
+
+
+ } + + @if (!showReceiveForm) { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ b.id }}{{ 'labels.inputs.Batch Reference' | translate }}{{ b.batchReference }}{{ 'labels.inputs.Session Date' | translate }} + {{ b.sessionDate | dateFormat }} + @if (b.receivedOn) { + {{ b.receivedOn | dateFormat: 'hh:mm A' }} + } + {{ 'labels.inputs.Presentment Type' | translate }}{{ b.presentmentType }}{{ 'labels.inputs.Items' | translate }}{{ b.itemCount ?? b.totalCount ?? '–' }}{{ 'labels.inputs.Amount' | translate }}{{ b.totalAmount ?? '–' }}{{ 'labels.inputs.Status' | translate }} + {{ b.status }} + + +
+ @if (batches.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+ } +
diff --git a/src/app/ctsclearing/inward-batches/inward-batches.component.scss b/src/app/ctsclearing/inward-batches/inward-batches.component.scss new file mode 100644 index 000000000..6dc85d27d --- /dev/null +++ b/src/app/ctsclearing/inward-batches/inward-batches.component.scss @@ -0,0 +1,150 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.header-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +mat-form-field { + width: 100%; +} + +.section-title { + margin: 12px 0 8px; + font-size: 0.95rem; +} + +.item-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.item-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px 12px; + align-items: center; + padding: 8px; + border: 1px solid rgb(0 0 0 / 8%); + border-radius: 8px; +} + +.row-remove { + align-self: center; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} + +.table-wrapper { + overflow-x: auto; + background: #fff; +} + +table { + width: 100%; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 3%); + } +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.muted { + color: rgb(0 0 0 / 50%); + font-size: 0.8rem; + margin-left: 4px; +} + +::ng-deep .ok-hint { + color: #28c07a; + font-weight: 600; +} + +::ng-deep .err-hint { + color: #d32f2f; + font-weight: 600; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} diff --git a/src/app/ctsclearing/inward-batches/inward-batches.component.ts b/src/app/ctsclearing/inward-batches/inward-batches.component.ts new file mode 100644 index 000000000..2dce47c42 --- /dev/null +++ b/src/app/ctsclearing/inward-batches/inward-batches.component.ts @@ -0,0 +1,282 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { CtsClearingService } from '../ctsclearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** + * Inward CTS presentment batches — cheques drawn on us, received from the clearing house. Lists batches + * and captures a new batch with inline per-cheque item rows before it is validated on the detail screen. + */ +@Component({ + selector: 'mifosx-inward-batches', + templateUrl: './inward-batches.component.html', + styleUrls: ['./inward-batches.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class InwardBatchesComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private ctsService = inject(CtsClearingService); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + + pillClass = pillClass; + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + batches: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + statusFilter = ''; + busy = false; + showReceiveForm = false; + + receiveForm: UntypedFormGroup; + filterForm: UntypedFormGroup; + + columns = [ + 'id', + 'batchReference', + 'sessionDate', + 'presentmentType', + 'items', + 'amount', + 'status', + 'actions' + ]; + + constructor() { + this.receiveForm = this.formBuilder.group({ + batchReference: [ + '', + Validators.required + ], + sessionDate: [ + '', + Validators.required + ], + presentmentType: [ + 'REGULAR', + Validators.required + ], + presentingBankCode: [''], + presentingBankName: [''], + items: this.formBuilder.array([]) + }); + // Restores whichever quick filter (Today/Yesterday/All) was last selected, so navigating to a batch and + // back doesn't silently jump back to "today" — persisted on the singleton service, not this component. + const restoredDates = this.datesForQuickFilter(this.ctsService.inwardBatchesDateFilter); + this.filterForm = this.formBuilder.group({ + fromDate: [restoredDates.fromDate], + toDate: [restoredDates.toDate], + batchReference: [''], + presentingBankName: [''] + }); + } + + get quickFilterMode(): 'today' | 'yesterday' | 'all' { + return this.ctsService.inwardBatchesDateFilter; + } + + private datesForQuickFilter(mode: 'today' | 'yesterday' | 'all'): { fromDate: Date | null; toDate: Date | null } { + if (mode === 'all') { + return { fromDate: null, toDate: null }; + } + const day = new Date(); + if (mode === 'yesterday') { + day.setDate(day.getDate() - 1); + } + return { fromDate: day, toDate: day }; + } + + setQuickFilter(mode: 'today' | 'yesterday' | 'all'): void { + this.ctsService.inwardBatchesDateFilter = mode; + const dates = this.datesForQuickFilter(mode); + this.filterForm.patchValue({ fromDate: dates.fromDate, toDate: dates.toDate }); + this.pageIndex = 0; + this.loadBatches(); + } + + ngOnInit(): void { + this.loadBatches(); + this.addItem(); + } + + get items(): UntypedFormArray { + return this.receiveForm.get('items') as UntypedFormArray; + } + + addItem(): void { + this.items.push( + this.formBuilder.group({ + chequeNumber: [ + '', + Validators.required + ], + micrCode: [''], + ifsc: [''], + payeeName: [''], + amount: [ + '', + Validators.required + ], + amountInWords: [''], + instrumentDate: [''], + presentedDate: [''], + accountType: [ + 'SAVINGS', + Validators.required + ], + accountId: [ + '', + Validators.required + ], + originalSerialNumber: [''], + presentingBranchCode: [''] + }) + ); + } + + removeItem(index: number): void { + this.items.removeAt(index); + } + + loadBatches(): void { + const f = this.filterForm.value; + const filters = { + fromDate: this.toApiDate(f.fromDate), + toDate: this.toApiDate(f.toDate), + batchReference: f.batchReference || undefined, + presentingBankName: f.presentingBankName || undefined + }; + this.ctsService + .getInwardBatches(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters) + .subscribe((res: any) => { + this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.loadBatches(); + } + + applyFilters(): void { + // A manual date edit no longer matches a plain Today/Yesterday/All quick state — treat it as custom. + this.ctsService.inwardBatchesDateFilter = 'all'; + this.pageIndex = 0; + this.loadBatches(); + } + + clearFilters(): void { + this.ctsService.inwardBatchesDateFilter = 'all'; + this.filterForm.reset({ fromDate: '', toDate: '', batchReference: '', presentingBankName: '' }); + this.pageIndex = 0; + this.loadBatches(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadBatches(); + } + + open(batch: any): void { + this.router.navigate([ + '/ctsclearing', + 'inward', + batch.id + ]); + } + + private toApiDate(value: any): string | undefined { + if (!value) { + return undefined; + } + return formatDate(value, 'yyyy-MM-dd', 'en'); + } + + submitReceive(): void { + if (this.receiveForm.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.receiveForm.value; + const payload = { + batchReference: raw.batchReference, + sessionDate: this.toApiDate(raw.sessionDate), + presentmentType: raw.presentmentType, + presentingBankCode: raw.presentingBankCode || undefined, + presentingBankName: raw.presentingBankName || undefined, + items: (raw.items || []).map((it: any) => ({ + chequeNumber: it.chequeNumber, + micrCode: it.micrCode || undefined, + ifsc: it.ifsc || undefined, + payeeName: it.payeeName || undefined, + amount: it.amount, + amountInWords: it.amountInWords || undefined, + instrumentDate: this.toApiDate(it.instrumentDate), + presentedDate: this.toApiDate(it.presentedDate), + accountType: it.accountType || undefined, + accountId: it.accountId || undefined, + originalSerialNumber: it.originalSerialNumber || undefined, + presentingBranchCode: it.presentingBranchCode || undefined + })), + dateFormat: 'yyyy-MM-dd', + locale: 'en' + }; + this.ctsService.createInwardBatch(payload).subscribe({ + next: () => { + this.busy = false; + this.showReceiveForm = false; + this.receiveForm.reset({ presentmentType: 'REGULAR' }); + this.items.clear(); + this.addItem(); + this.notify('Inward batch received'); + this.loadBatches(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not receive the batch'), true); + } + }); + } +} diff --git a/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.html b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.html new file mode 100644 index 000000000..0a9e05db2 --- /dev/null +++ b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.html @@ -0,0 +1,192 @@ + + +
+ + +
+ @for (s of ['SENT_TO_CLEARING_HOUSE', 'SETTLED', 'DISHONOURED']; track s) { + + } +
+ + + +
+
+ + {{ 'labels.inputs.Cheque Number' | translate }} + + + + {{ 'labels.inputs.Drawer Bank Name' | translate }} + + + + {{ 'labels.inputs.Payee Name' | translate }} + + +
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ p.id }}{{ 'labels.inputs.Cheque Number' | translate }}{{ p.chequeNumber }}{{ 'labels.inputs.Drawer Bank' | translate }} + {{ p.drawerBankName || '–' }} + @if (p.drawerIfsc) { + {{ p.drawerIfsc }} + } + {{ 'labels.inputs.Payee Name' | translate }}{{ p.payeeName }}{{ 'labels.inputs.Amount' | translate }}{{ p.amount }}{{ 'labels.inputs.Image' | translate }} +
+ @if (getItemImageUrl(p, 'front')) { + front + } + @if (getItemImageUrl(p, 'back')) { + back + } +
+
{{ 'labels.inputs.Status' | translate }} + {{ p.status }} + @if (p.iqaStatus && p.iqaStatus !== 'NA') { + IQA: {{ p.iqaStatus }} + } + + @if (p.status === 'SENT_TO_CLEARING_HOUSE') { + + + } + @if (p.ctsFileJson) { + + } +
+ @if (presentments.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @for (p of presentments; track p.id) { + @if (shownEnvelopeId === p.id && p.ctsFileJson) { +
+
+ 📄 CTS-2010 outward file sent to the clearing house — cheque #{{ p.chequeNumber }} +
+
{{ prettyJson(p.ctsFileJson) }}
+ @if (p.npciResponseJson) { +
📥 NPCI's real routing response for this batch:
+
{{ prettyJson(p.npciResponseJson) }}
+ } +
+ } + } + @if (totalRecords > 0) { + + } +
+ + @if (dishonourForId) { + + +

{{ 'labels.inputs.Dishonour Cheque' | translate }}

+
+
+ + {{ 'labels.inputs.Dishonour Reason Code' | translate }} + + @for (c of reasonCodes; track c.id) { + {{ c.code }} — {{ c.description }} + } + + +
+
+ + +
+
+
+
+ } + +
+
diff --git a/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.scss b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.scss new file mode 100644 index 000000000..77a7b5547 --- /dev/null +++ b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.scss @@ -0,0 +1,145 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +mat-form-field { + width: 100%; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} + +.table-wrapper { + overflow-x: auto; + background: #fff; +} + +table { + width: 100%; +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.muted { + display: block; + color: rgb(0 0 0 / 50%); + font-size: 0.78rem; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + margin-right: 4px; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} + +.envelope-panel { + margin: 8px; + padding: 12px; + border: 1px solid #e0e0e0; + border-radius: 6px; + background: #fafafa; + + .envelope-head { + font-weight: 600; + margin-bottom: 6px; + } +} + +.image-cell { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.cheque-thumb { + width: 40px; + height: 28px; + object-fit: cover; + border: 1px solid #ddd; + border-radius: 3px; + cursor: pointer; +} + +.cts-file { + font-family: monospace; + font-size: 0.74rem; + background: #f5f7fa; + color: #1a2733; + border: 1px solid #dfe6ee; + padding: 10px; + border-radius: 4px; + max-height: 360px; + overflow: auto; + white-space: pre; + margin: 4px 0 12px; +} diff --git a/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.ts b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.ts new file mode 100644 index 000000000..367dafb24 --- /dev/null +++ b/src/app/ctsclearing/outward-batch-detail/outward-batch-detail.component.ts @@ -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; + 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; + } + } +} diff --git a/src/app/ctsclearing/outward-presentment/outward-presentment.component.html b/src/app/ctsclearing/outward-presentment/outward-presentment.component.html new file mode 100644 index 000000000..41d37ecca --- /dev/null +++ b/src/app/ctsclearing/outward-presentment/outward-presentment.component.html @@ -0,0 +1,434 @@ + + +
+ + + @if (showCaptureForm) { + + +
+
+ + {{ 'labels.inputs.Depositing Account Type' | translate }} + + {{ 'labels.inputs.Savings' | translate }} + {{ 'labels.inputs.Current' | translate }} + + + + {{ 'labels.inputs.Depositing Account Id' | translate }} + + + + {{ 'labels.inputs.Cheque Number' | translate }} + + + + {{ 'labels.inputs.Amount' | translate }} + + + + {{ 'labels.inputs.Payee Name' | translate }} + + + + {{ 'labels.inputs.Drawer Bank Name' | translate }} + + + + {{ 'labels.inputs.Drawer Bank MICR' | translate }} + + + + {{ 'labels.inputs.Drawer IFSC' | translate }} + + + + Drawer Account Number + + + + {{ 'labels.inputs.Deposited Date' | translate }} + + + + + + IQA Status + + PASS + FAIL + PENDING + NA + + + + IQA Detail + + +
+
+ @if (frontPreviewUrl) { + front + } + @if (backPreviewUrl) { + back + } + + + + + @if (!selectedFrontFile || !selectedBackFile) { + Both front & back images are required + } +
+
+ Stop Payment on this cheque +
+
+ + +
+
+
+
+ } + + @if (!showCaptureForm) { + + + + +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + {{ 'labels.inputs.Cheque Number' | translate }} + + + + {{ 'labels.inputs.Drawer Bank Name' | translate }} + + + + {{ 'labels.inputs.Payee Name' | translate }} + + +
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ p.id }}{{ 'labels.inputs.Cheque Number' | translate }}{{ p.chequeNumber }}{{ 'labels.inputs.Drawer Bank' | translate }} + {{ p.drawerBankName || '–' }} + @if (p.drawerIfsc) { + {{ p.drawerIfsc }} + } + {{ 'labels.inputs.Payee Name' | translate }}{{ p.payeeName }}{{ 'labels.inputs.Amount' | translate }}{{ p.amount }}{{ 'labels.inputs.Deposited Date' | translate }} + {{ p.depositedDate ? (p.depositedDate | dateFormat) : '–' }} + @if (p.createdOn) { + {{ p.createdOn | dateFormat: 'hh:mm A' }} + } + {{ 'labels.inputs.Image' | translate }} +
+ @if (getItemImageUrl(p, 'front')) { + front + } @else { + + + } + @if (getItemImageUrl(p, 'back')) { + back + } @else { + + + } +
+
{{ 'labels.inputs.Status' | translate }} + {{ p.status }} + @if (p.stopPaymentFlag) { + STOP + } + @if (p.iqaStatus && p.iqaStatus !== 'NA') { + IQA: {{ p.iqaStatus }} + } + @if (getItemImageUrl(p, 'front') && getItemImageUrl(p, 'back')) { + + Awaiting next clearing session + + } @else { + Image required + } +
+ @if (presentments.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (pendingTotalRecords > 0) { + + } +
+
+ +
+ + +
+ + +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + {{ 'labels.inputs.Batch Reference' | translate }} + + +
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Batch Reference' | translate }}{{ b.batchReference }}Session + {{ b.sessionStartedOn | dateFormat: 'DD MMM YYYY, hh:mm A' }} + @if (b.sessionEndedOn) { + to {{ b.sessionEndedOn | dateFormat: 'hh:mm A' }} + } + {{ 'labels.inputs.Items' | translate }}{{ b.itemCount ?? '–' }}{{ 'labels.inputs.Total Amount' | translate }}{{ b.totalAmount ?? '–' }}{{ 'labels.inputs.Status' | translate }} + @if (b.settledCount) { + {{ b.settledCount }} settled + } + @if (b.dishonouredCount) { + {{ b.dishonouredCount }} dishonoured + } + @if (b.sentCount) { + {{ b.sentCount }} awaiting settlement + } + + +
+ @if (batches.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (batchesTotalRecords > 0) { + + } +
+
+
+ } + +
+
diff --git a/src/app/ctsclearing/outward-presentment/outward-presentment.component.scss b/src/app/ctsclearing/outward-presentment/outward-presentment.component.scss new file mode 100644 index 000000000..5671d6f5e --- /dev/null +++ b/src/app/ctsclearing/outward-presentment/outward-presentment.component.scss @@ -0,0 +1,172 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.header-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +mat-form-field { + width: 100%; +} + +.stop-payment-row { + margin: 4px 0 12px; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} + +.outward-tabs { + margin-top: 8px; + + ::ng-deep .mat-mdc-tab-body-wrapper { + padding-top: 16px; + } +} + +.table-wrapper { + overflow-x: auto; + background: #fff; + margin-bottom: 16px; +} + +table { + width: 100%; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background: rgb(0 0 0 / 3%); + } +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.muted { + display: block; + color: rgb(0 0 0 / 50%); + font-size: 0.78rem; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + margin-right: 4px; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} + +.envelope-panel { + margin: 8px; + padding: 12px; + border: 1px solid #e0e0e0; + border-radius: 6px; + background: #fafafa; + + .envelope-head { + font-weight: 600; + margin-bottom: 6px; + } +} + +.image-cell { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.cheque-thumb { + width: 40px; + height: 28px; + object-fit: cover; + border: 1px solid #ddd; + border-radius: 3px; + cursor: pointer; +} + +.cts-file { + font-family: monospace; + font-size: 0.74rem; + background: #f5f7fa; + color: #1a2733; + border: 1px solid #dfe6ee; + padding: 10px; + border-radius: 4px; + max-height: 360px; + overflow: auto; + white-space: pre; + margin: 4px 0 12px; +} diff --git a/src/app/ctsclearing/outward-presentment/outward-presentment.component.ts b/src/app/ctsclearing/outward-presentment/outward-presentment.component.ts new file mode 100644 index 000000000..dc681c68e --- /dev/null +++ b/src/app/ctsclearing/outward-presentment/outward-presentment.component.ts @@ -0,0 +1,537 @@ +/** + * 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); + } + }); + } +} diff --git a/src/app/ctsclearing/positive-pay/positive-pay.component.html b/src/app/ctsclearing/positive-pay/positive-pay.component.html new file mode 100644 index 000000000..4026f1db5 --- /dev/null +++ b/src/app/ctsclearing/positive-pay/positive-pay.component.html @@ -0,0 +1,134 @@ + + +
+
+
+

Positive Pay (RBI PPS)

+

+ Pre-register high-value cheques (≥ ₹50,000). Inward clearing returns an unregistered or mismatched high-value + cheque with reason code 13. +

+
+ +
+ + @if (showForm) { +
+
+ + Account Type + + Savings + Current + + + + Account Id + + + + Cheque Number + + +
+
+ + Instrument Date + + + + + + Amount + + + + Payee Name + + + + Channel + + Branch + Net Banking + Mobile + + +
+
+ + +
+
+ } + +
+ @for (s of ['ACTIVE', 'CANCELLED', 'CONSUMED']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ r.id }}Account + {{ r.accountNo || r.accountId }} ({{ r.accountType }}) + Cheque #{{ r.chequeNumber }}Instrument Date{{ r.instrumentDate | date: 'mediumDate' }}Amount{{ r.amount }}Payee{{ r.payeeName }}Status + {{ r.status }} + + @if (r.status === 'ACTIVE') { + + } +
+ +
+
diff --git a/src/app/ctsclearing/positive-pay/positive-pay.component.scss b/src/app/ctsclearing/positive-pay/positive-pay.component.scss new file mode 100644 index 000000000..3260e0922 --- /dev/null +++ b/src/app/ctsclearing/positive-pay/positive-pay.component.scss @@ -0,0 +1,89 @@ +/** + * 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/. + */ + +.page { + padding: 1.5rem; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.muted { + color: rgb(0 0 0 / 55%); + font-size: 0.85rem; +} + +.capture-form { + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + + .row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 200px; + } + } + + .actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } +} + +.filters { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + + button.active { + background: #3f51b5; + color: #fff; + } +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; +} + +.pill { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + + &.ok { + background: #e6f4ea; + color: #1e7e34; + } + + &.bad { + background: #fdecea; + color: #c62828; + } + + &.neutral { + background: #eceff1; + color: #546e7a; + } +} diff --git a/src/app/ctsclearing/positive-pay/positive-pay.component.ts b/src/app/ctsclearing/positive-pay/positive-pay.component.ts new file mode 100644 index 000000000..954318775 --- /dev/null +++ b/src/app/ctsclearing/positive-pay/positive-pay.component.ts @@ -0,0 +1,178 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { CtsClearingService } from '../ctsclearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** + * Positive Pay System (RBI PPS) — pre-registration of high-value cheque particulars. Inward clearing matches + * a presented high-value cheque against an active registration before honouring it. + */ +@Component({ + selector: 'mifosx-positive-pay', + templateUrl: './positive-pay.component.html', + styleUrls: ['./positive-pay.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class PositivePayComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private ctsService = inject(CtsClearingService); + private snackBar = inject(MatSnackBar); + + pillClass = pillClass; + + registrations: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + statusFilter = ''; + busy = false; + showForm = false; + form: UntypedFormGroup; + + columns = [ + 'id', + 'account', + 'chequeNumber', + 'instrumentDate', + 'amount', + 'payeeName', + 'status', + 'actions' + ]; + + constructor() { + this.form = this.formBuilder.group({ + accountType: [ + 'SAVINGS', + Validators.required + ], + accountId: [ + '', + Validators.required + ], + chequeNumber: [ + '', + Validators.required + ], + instrumentDate: [''], + amount: [ + '', + Validators.required + ], + payeeName: [''], + channel: ['BRANCH'] + }); + } + + ngOnInit(): void { + this.load(); + } + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + private toApiDate(value: any): string | undefined { + return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined; + } + + load(): void { + this.ctsService + .getPositivePayRegistrations( + undefined, + this.statusFilter || undefined, + this.pageIndex * this.pageSize, + this.pageSize + ) + .subscribe((res: any) => { + this.registrations = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.registrations.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.load(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + submit(): void { + if (this.form.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.form.value; + const payload = { + ...raw, + instrumentDate: this.toApiDate(raw.instrumentDate), + dateFormat: 'yyyy-MM-dd', + locale: 'en' + }; + this.ctsService.registerPositivePay(payload).subscribe({ + next: () => { + this.busy = false; + this.showForm = false; + this.form.reset({ accountType: 'SAVINGS', channel: 'BRANCH' }); + this.notify('Positive Pay registered'); + this.load(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not register Positive Pay'), true); + } + }); + } + + cancel(registration: any): void { + const reason = window.prompt('Reason for cancelling this Positive Pay registration?'); + if (!reason) { + return; + } + this.ctsService.cancelPositivePay(registration.id, { reason }).subscribe({ + next: () => { + this.notify('Positive Pay cancelled'); + this.load(); + }, + error: (err: any) => this.notify(clearingError(err, 'Could not cancel'), true) + }); + } +} diff --git a/src/app/ctsclearing/reconciliation/reconciliation.component.html b/src/app/ctsclearing/reconciliation/reconciliation.component.html new file mode 100644 index 000000000..960fe106f --- /dev/null +++ b/src/app/ctsclearing/reconciliation/reconciliation.component.html @@ -0,0 +1,110 @@ + + +
+ + + @if (showRunForm) { + + +
+
+ + {{ 'labels.inputs.Reconciliation Date' | translate }} + + + + + + {{ 'labels.inputs.Source Reference' | translate }} + + + + {{ 'labels.inputs.CBS Total' | translate }} + + + + {{ 'labels.inputs.Clearing House Total' | translate }} + + + + {{ 'labels.inputs.Detail' | translate }} + + +
+
+ + +
+
+
+
+ } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ r.id }}{{ 'labels.inputs.Reconciliation Date' | translate }}{{ r.reconciliationDate ? (r.reconciliationDate | dateFormat) : '–' }}{{ 'labels.inputs.Source Reference' | translate }}{{ r.sourceReference || '–' }}{{ 'labels.inputs.CBS Total' | translate }}{{ r.cbsTotal }}{{ 'labels.inputs.Clearing House Total' | translate }}{{ r.clearingHouseTotal }}{{ 'labels.inputs.Difference' | translate }}{{ r.difference ?? '–' }}{{ 'labels.inputs.Status' | translate }} + {{ r.status }} +
+ @if (reconciliations.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+
diff --git a/src/app/ctsclearing/reconciliation/reconciliation.component.scss b/src/app/ctsclearing/reconciliation/reconciliation.component.scss new file mode 100644 index 000000000..1470617d9 --- /dev/null +++ b/src/app/ctsclearing/reconciliation/reconciliation.component.scss @@ -0,0 +1,90 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.header-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.add-form { + margin-bottom: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +mat-form-field { + width: 100%; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} + +.table-wrapper { + overflow-x: auto; + background: #fff; +} + +table { + width: 100%; +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} diff --git a/src/app/ctsclearing/reconciliation/reconciliation.component.ts b/src/app/ctsclearing/reconciliation/reconciliation.component.ts new file mode 100644 index 000000000..351074443 --- /dev/null +++ b/src/app/ctsclearing/reconciliation/reconciliation.component.ts @@ -0,0 +1,148 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ClearingService } from '../../clearing-network/clearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** + * CTS view of the shared reconciliation register — records CBS vs clearing-house totals for a date and shows + * whether they MATCHED or MISMATCHed. clearingType is fixed to 'CTS'. + */ +@Component({ + selector: 'mifosx-cts-reconciliation', + templateUrl: './reconciliation.component.html', + styleUrls: ['./reconciliation.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class ReconciliationComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private clearingService = inject(ClearingService); + private snackBar = inject(MatSnackBar); + + readonly clearingType = 'CTS'; + pillClass = pillClass; + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + reconciliations: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + busy = false; + showRunForm = false; + + runForm: UntypedFormGroup; + + columns = [ + 'id', + 'reconciliationDate', + 'sourceReference', + 'cbsTotal', + 'clearingHouseTotal', + 'difference', + 'status' + ]; + + constructor() { + this.runForm = this.formBuilder.group({ + reconciliationDate: [ + '', + Validators.required + ], + sourceReference: [''], + cbsTotal: [ + '', + Validators.required + ], + clearingHouseTotal: [ + '', + Validators.required + ], + detail: [''] + }); + } + + ngOnInit(): void { + this.loadReconciliations(); + } + + loadReconciliations(): void { + this.clearingService + .getReconciliations(this.clearingType, this.pageIndex * this.pageSize, this.pageSize) + .subscribe((res: any) => { + this.reconciliations = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.reconciliations.length); + }); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadReconciliations(); + } + + submitRun(): void { + if (this.runForm.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.runForm.value; + const payload = { + clearingType: this.clearingType, + reconciliationDate: formatDate(raw.reconciliationDate, 'yyyy-MM-dd', 'en'), + sourceReference: raw.sourceReference || undefined, + cbsTotal: raw.cbsTotal, + clearingHouseTotal: raw.clearingHouseTotal, + detail: raw.detail || undefined, + dateFormat: 'yyyy-MM-dd', + locale: 'en' + }; + this.clearingService.runReconciliation(payload).subscribe({ + next: () => { + this.busy = false; + this.showRunForm = false; + this.runForm.reset(); + this.notify('Reconciliation recorded'); + this.loadReconciliations(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Reconciliation failed'), true); + } + }); + } +} diff --git a/src/app/ctsclearing/settlement/settlement.component.html b/src/app/ctsclearing/settlement/settlement.component.html new file mode 100644 index 000000000..1a6babbab --- /dev/null +++ b/src/app/ctsclearing/settlement/settlement.component.html @@ -0,0 +1,116 @@ + + +
+ + +
+ @for (s of ['CREATED', 'POSTED', 'REJECTED']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.Id' | translate }}{{ b.id }}{{ 'labels.inputs.Settlement Date' | translate }}{{ b.settlementDate ? (b.settlementDate | dateFormat) : '–' }}{{ 'labels.inputs.Source Reference' | translate }}{{ b.sourceReference }}{{ 'labels.inputs.Passed' | translate }} + {{ b.passedCount ?? '–' }} + @if (b.totalPassedAmount != null) { + {{ b.totalPassedAmount }} + } + {{ 'labels.inputs.Returned' | translate }} + {{ b.returnedCount ?? '–' }} + @if (b.totalReturnedAmount != null) { + {{ b.totalReturnedAmount }} + } + {{ 'labels.inputs.Net Amount' | translate }}{{ b.netAmount ?? '–' }}{{ 'labels.inputs.Status' | translate }} + {{ b.status }} + @if (b.glTransactionId) { + GL #{{ b.glTransactionId }} + } + + @if (b.status === 'CREATED') { + + + } +
+ @if (batches.length === 0) { +
{{ 'labels.text.No records to display' | translate }}
+ } + @if (totalRecords > 0) { + + } +
+ + @if (rejectForId) { + + +

{{ 'labels.inputs.Reject Settlement' | translate }}

+
+
+ + {{ 'labels.inputs.Reason' | translate }} + + +
+
+ + +
+
+
+
+ } +
diff --git a/src/app/ctsclearing/settlement/settlement.component.scss b/src/app/ctsclearing/settlement/settlement.component.scss new file mode 100644 index 000000000..c0e6af950 --- /dev/null +++ b/src/app/ctsclearing/settlement/settlement.component.scss @@ -0,0 +1,102 @@ +/** + * 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/. + */ + +.container { + padding: 16px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; + + button.active { + background: rgb(25 118 210 / 12%); + } +} + +.add-form { + margin-bottom: 16px; + margin-top: 16px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px 20px; + margin-bottom: 8px; +} + +mat-form-field { + width: 100%; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} + +.table-wrapper { + overflow-x: auto; + background: #fff; +} + +table { + width: 100%; +} + +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.muted { + display: block; + color: rgb(0 0 0 / 50%); + font-size: 0.78rem; +} + +.pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; + + &.ok { + background: #dff5e8; + color: #28c07a; + } + + &.bad { + background: #fde3e3; + color: #d32f2f; + } + + &.neutral { + background: #e0e0e0; + color: #757575; + } +} + +.empty-state { + padding: 24px; + text-align: center; + color: rgb(0 0 0 / 50%); +} diff --git a/src/app/ctsclearing/settlement/settlement.component.ts b/src/app/ctsclearing/settlement/settlement.component.ts new file mode 100644 index 000000000..721a93d30 --- /dev/null +++ b/src/app/ctsclearing/settlement/settlement.component.ts @@ -0,0 +1,161 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ClearingService } from '../../clearing-network/clearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** + * CTS view of the shared net-settlement batches — prepared from validated inward batches, posted to the GL + * or rejected. clearingType is fixed to 'CTS'. + */ +@Component({ + selector: 'mifosx-cts-settlement', + templateUrl: './settlement.component.html', + styleUrls: ['./settlement.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class SettlementComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private clearingService = inject(ClearingService); + private snackBar = inject(MatSnackBar); + + readonly clearingType = 'CTS'; + pillClass = pillClass; + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + batches: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + statusFilter = ''; + busy = false; + + rejectForId: number | null = null; + rejectForm: UntypedFormGroup; + + columns = [ + 'id', + 'settlementDate', + 'sourceReference', + 'passed', + 'returned', + 'netAmount', + 'status', + 'actions' + ]; + + constructor() { + this.rejectForm = this.formBuilder.group({ + reason: [ + '', + Validators.required + ] + }); + } + + ngOnInit(): void { + this.loadBatches(); + } + + loadBatches(): void { + this.clearingService + .getSettlementBatches( + this.clearingType, + this.statusFilter || undefined, + this.pageIndex * this.pageSize, + this.pageSize + ) + .subscribe((res: any) => { + this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.loadBatches(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.loadBatches(); + } + + post(batch: any): void { + if (this.busy) { + return; + } + this.busy = true; + this.clearingService.postSettlement(batch.id).subscribe({ + next: () => { + this.busy = false; + this.notify('Settlement posted'); + this.loadBatches(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Post failed'), true); + } + }); + } + + toggleReject(batch: any): void { + this.rejectForId = this.rejectForId === batch.id ? null : batch.id; + this.rejectForm.reset(); + } + + submitReject(id: number): void { + if (this.rejectForm.invalid || this.busy) { + return; + } + this.busy = true; + this.clearingService.rejectSettlement(id, { reason: this.rejectForm.value.reason, locale: 'en' }).subscribe({ + next: () => { + this.busy = false; + this.rejectForId = null; + this.notify('Settlement rejected'); + this.loadBatches(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Reject failed'), true); + } + }); + } +} diff --git a/src/app/nachclearing/batch-detail/batch-detail.component.html b/src/app/nachclearing/batch-detail/batch-detail.component.html new file mode 100644 index 000000000..7a14db07e --- /dev/null +++ b/src/app/nachclearing/batch-detail/batch-detail.component.html @@ -0,0 +1,96 @@ + + +
+ @if (batch) { +
+
+

Batch {{ batch.batchReference }}

+

+ {{ batch.batchType }} · value {{ batch.valueDate | date: 'mediumDate' }} · {{ batch.totalTxnCount }} txns · + {{ batch.totalAmount }} + {{ batch.status }} +

+
+
+ @if (batch.status === 'CREATED') { + + } + @if (batch.status === 'PROCESSED') { + + } +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ t.id }}Account + {{ t.accountNo || t.accountId }} ({{ t.accountType }}) + Type{{ t.txnType }}Amount{{ t.amount }}Validation + {{ c.label[0] }} + Decision + {{ t.decision }} + @if (t.returnReasonCode) { + {{ t.returnReasonCode }} + } + Status + {{ t.status }} + + @if (t.decision === 'PENDING') { + + } +
+
+ } +
diff --git a/src/app/nachclearing/batch-detail/batch-detail.component.scss b/src/app/nachclearing/batch-detail/batch-detail.component.scss new file mode 100644 index 000000000..d802096c4 --- /dev/null +++ b/src/app/nachclearing/batch-detail/batch-detail.component.scss @@ -0,0 +1,86 @@ +/** + * 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/. + */ + +.page { + padding: 1.5rem; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; + gap: 1rem; + flex-wrap: wrap; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +.muted { + color: rgb(0 0 0 / 55%); + font-size: 0.85rem; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; +} + +.check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 3px; + border-radius: 50%; + font-size: 0.7rem; + font-weight: 700; + color: #fff; + + &.ok { + background: #2e7d32; + } + + &.bad { + background: #c62828; + } + + &.na { + background: #b0bec5; + } +} + +.pill { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + + &.ok { + background: #e6f4ea; + color: #1e7e34; + } + + &.bad { + background: #fdecea; + color: #c62828; + } + + &.neutral { + background: #eceff1; + color: #546e7a; + } +} diff --git a/src/app/nachclearing/batch-detail/batch-detail.component.ts b/src/app/nachclearing/batch-detail/batch-detail.component.ts new file mode 100644 index 000000000..aa68c79b0 --- /dev/null +++ b/src/app/nachclearing/batch-detail/batch-detail.component.ts @@ -0,0 +1,130 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { NachClearingService } from '../nachclearing.service'; +import { ClearingService } from '../../clearing-network/clearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; +import { TxnDecisionDialogComponent } from './txn-decision-dialog/txn-decision-dialog.component'; + +/** One NACH batch: its transactions, their validation results, bulk processing and manual overrides. */ +@Component({ + selector: 'mifosx-nach-batch-detail', + templateUrl: './batch-detail.component.html', + styleUrls: ['./batch-detail.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatSnackBarModule, + MatDialogModule, + FaIconComponent + ] +}) +export class BatchDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private nachService = inject(NachClearingService); + private clearingService = inject(ClearingService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + pillClass = pillClass; + + batchId!: string; + batch: any = null; + transactions: any[] = []; + busy = false; + + columns = [ + 'id', + 'account', + 'txnType', + 'amount', + 'checks', + 'decision', + 'status', + 'actions' + ]; + + ngOnInit(): void { + this.batchId = this.route.snapshot.paramMap.get('id') as string; + this.reload(); + } + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + reload(): void { + this.nachService.getBatch(this.batchId).subscribe((b: any) => (this.batch = b)); + this.nachService.getBatchTransactions(this.batchId).subscribe((res: any) => { + this.transactions = res?.pageItems ?? (Array.isArray(res) ? res : []); + }); + } + + checks(t: any): { label: string; ok: boolean | null }[] { + return [ + { label: 'Account', ok: t.accountCheckPassed }, + { label: 'Mandate', ok: t.mandateActiveCheck }, + { label: 'Limit', ok: t.amountWithinLimitCheck }, + { label: 'Balance', ok: t.balanceCheckPassed }, + { label: 'Dup', ok: t.duplicateCheckPassed } + ]; + } + + process(): void { + if (this.busy) { + return; + } + this.busy = true; + this.nachService.processBatch(this.batchId).subscribe({ + next: () => { + this.busy = false; + this.notify('Batch processed'); + this.reload(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not process the batch'), true); + } + }); + } + + prepareSettlement(): void { + this.clearingService.prepareSettlement({ clearingType: 'NACH', sourceBatchId: this.batchId }).subscribe({ + next: () => { + this.notify('Settlement batch prepared'); + this.reload(); + }, + error: (err: any) => this.notify(clearingError(err, 'Could not prepare settlement'), true) + }); + } + + decide(txn: any): void { + this.dialog + .open(TxnDecisionDialogComponent, { width: '520px', data: { transaction: txn } }) + .afterClosed() + .subscribe((result: any) => { + if (result?.actioned) { + this.reload(); + } + }); + } +} diff --git a/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.html b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.html new file mode 100644 index 000000000..db6a645f6 --- /dev/null +++ b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.html @@ -0,0 +1,41 @@ + + +

Decide Transaction #{{ data.transaction.id }}

+ +

+ {{ data.transaction.txnType }} · {{ data.transaction.accountType }} account + {{ data.transaction.accountNo || data.transaction.accountId }} · amount {{ data.transaction.amount }} +

+ +
+ + +
+ +
+ @if (decision === 'RETURN') { + + Return Reason Code + + @for (rc of reasonCodes; track rc.code) { + {{ rc.code }} — {{ rc.description }} + } + + + } + + Remarks + + +
+
+ + + + diff --git a/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.scss b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.scss new file mode 100644 index 000000000..143c5ebd1 --- /dev/null +++ b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.scss @@ -0,0 +1,33 @@ +/** + * 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/. + */ + +.muted { + color: rgb(0 0 0 / 60%); + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + + .sel-ok { + background: #2e7d32; + color: #fff; + } + + .sel-bad { + background: #c62828; + color: #fff; + } +} + +.full { + width: 100%; +} diff --git a/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.ts b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.ts new file mode 100644 index 000000000..8ac0a34a7 --- /dev/null +++ b/src/app/nachclearing/batch-detail/txn-decision-dialog/txn-decision-dialog.component.ts @@ -0,0 +1,108 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { NachClearingService } from '../../nachclearing.service'; +import { ClearingService } from '../../../clearing-network/clearing.service'; +import { clearingError } from '../../../clearing-network/clearing-status'; + +/** Pass or return a single NACH transaction. Return requires a standardized reason code. */ +@Component({ + selector: 'mifosx-nach-txn-decision-dialog', + templateUrl: './txn-decision-dialog.component.html', + styleUrls: ['./txn-decision-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatButtonModule, + MatDialogModule, + MatSnackBarModule + ] +}) +export class TxnDecisionDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + data = inject<{ transaction: any }>(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private nachService = inject(NachClearingService); + private clearingService = inject(ClearingService); + private snackBar = inject(MatSnackBar); + + decision: 'PASS' | 'RETURN' = 'PASS'; + reasonCodes: any[] = []; + busy = false; + form: UntypedFormGroup; + + constructor() { + this.form = this.formBuilder.group({ + returnReasonCode: [''], + reason: [''] + }); + } + + ngOnInit(): void { + this.clearingService.getReturnReasonCodes('NACH').subscribe((res: any) => { + this.reasonCodes = Array.isArray(res) ? res : (res?.pageItems ?? []); + }); + } + + setDecision(decision: 'PASS' | 'RETURN'): void { + this.decision = decision; + const rc = this.form.get('returnReasonCode'); + if (decision === 'RETURN') { + rc?.setValidators([Validators.required]); + } else { + rc?.clearValidators(); + } + rc?.updateValueAndValidity(); + } + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + submit(): void { + if (this.busy) { + return; + } + const id = this.data.transaction.id; + this.busy = true; + if (this.decision === 'PASS') { + this.nachService.passTransaction(id).subscribe({ + next: () => this.done('Transaction passed'), + error: (err: any) => this.fail(err, 'Could not pass the transaction') + }); + } else { + if (this.form.invalid) { + this.busy = false; + return; + } + this.nachService.returnTransaction(id, this.form.value).subscribe({ + next: () => this.done('Transaction returned'), + error: (err: any) => this.fail(err, 'Could not return the transaction') + }); + } + } + + private done(message: string): void { + this.busy = false; + this.notify(message); + this.dialogRef.close({ actioned: true }); + } + + private fail(err: any, fallback: string): void { + this.busy = false; + this.notify(clearingError(err, fallback), true); + } +} diff --git a/src/app/nachclearing/batches/batches.component.html b/src/app/nachclearing/batches/batches.component.html new file mode 100644 index 000000000..8f3532462 --- /dev/null +++ b/src/app/nachclearing/batches/batches.component.html @@ -0,0 +1,142 @@ + + +
+
+

NACH Batches

+ +
+ + @if (showForm) { +
+
+ + Batch Type + + Debit + Credit + + + + Value Date + + + + + + Sponsor Bank Id + + +
+ +

Transactions

+
+ @for (t of transactions.controls; track t; let i = $index) { +
+ + Mandate Id + + + + Acct Type + + Savings + Current + + + + Account Id + + + + Amount + + + +
+ } +
+ + +
+ + +
+
+ } + +
+ @for (s of ['CREATED', 'PROCESSED', 'SETTLED']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ b.id }}Reference{{ b.batchReference }}Type{{ b.batchType }}Value Date{{ b.valueDate | date: 'mediumDate' }}Txns{{ b.totalTxnCount }}Amount{{ b.totalAmount }}Status + {{ b.status }} + + +
+ +
+
diff --git a/src/app/nachclearing/batches/batches.component.scss b/src/app/nachclearing/batches/batches.component.scss new file mode 100644 index 000000000..192878622 --- /dev/null +++ b/src/app/nachclearing/batches/batches.component.scss @@ -0,0 +1,98 @@ +/** + * 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/. + */ + +.page { + padding: 1.5rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.capture-form { + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + + .row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 200px; + } + } + + .txn-row { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 140px; + } + } + + .actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; + } +} + +.filters { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + + button.active { + background: #3f51b5; + color: #fff; + } +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; +} + +.clickable { + cursor: pointer; +} + +.pill { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + + &.ok { + background: #e6f4ea; + color: #1e7e34; + } + + &.bad { + background: #fdecea; + color: #c62828; + } + + &.neutral { + background: #eceff1; + color: #546e7a; + } +} diff --git a/src/app/nachclearing/batches/batches.component.ts b/src/app/nachclearing/batches/batches.component.ts new file mode 100644 index 000000000..268e1ca38 --- /dev/null +++ b/src/app/nachclearing/batches/batches.component.ts @@ -0,0 +1,191 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { NachClearingService } from '../nachclearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** NACH batches — a sponsor bank's set of due debits/credits, decided in bulk when processed. */ +@Component({ + selector: 'mifosx-nach-batches', + templateUrl: './batches.component.html', + styleUrls: ['./batches.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class BatchesComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private nachService = inject(NachClearingService); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + + pillClass = pillClass; + + batches: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + statusFilter = ''; + busy = false; + showForm = false; + + form: UntypedFormGroup; + + columns = [ + 'id', + 'batchReference', + 'batchType', + 'valueDate', + 'txns', + 'amount', + 'status', + 'actions' + ]; + + constructor() { + this.form = this.formBuilder.group({ + batchType: [ + 'DEBIT', + Validators.required + ], + valueDate: [ + '', + Validators.required + ], + sponsorBankId: [''], + transactions: this.formBuilder.array([]) + }); + } + + ngOnInit(): void { + this.load(); + this.addTxn(); + } + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + private toApiDate(value: any): string | undefined { + return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined; + } + + get transactions(): UntypedFormArray { + return this.form.get('transactions') as UntypedFormArray; + } + + addTxn(): void { + this.transactions.push( + this.formBuilder.group({ + mandateId: [''], + accountType: ['SAVINGS'], + accountId: [''], + amount: [ + '', + Validators.required + ] + }) + ); + } + + removeTxn(index: number): void { + this.transactions.removeAt(index); + } + + load(): void { + this.nachService + .getBatches(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize) + .subscribe((res: any) => { + this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.load(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + open(batch: any): void { + this.router.navigate([ + '/nachclearing', + 'batches', + batch.id + ]); + } + + submit(): void { + if (this.form.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.form.value; + const payload = { + batchType: raw.batchType, + valueDate: this.toApiDate(raw.valueDate), + sponsorBankId: raw.sponsorBankId || undefined, + transactions: (raw.transactions || []).map((t: any) => ({ + mandateId: t.mandateId || undefined, + accountType: t.accountType || undefined, + accountId: t.accountId || undefined, + amount: t.amount, + txnType: raw.batchType + })), + dateFormat: 'yyyy-MM-dd', + locale: 'en' + }; + this.nachService.createBatch(payload).subscribe({ + next: () => { + this.busy = false; + this.showForm = false; + this.form.reset({ batchType: 'DEBIT' }); + this.transactions.clear(); + this.addTxn(); + this.notify('NACH batch created'); + this.load(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not create the batch'), true); + } + }); + } +} diff --git a/src/app/nachclearing/mandates/mandates.component.html b/src/app/nachclearing/mandates/mandates.component.html new file mode 100644 index 000000000..d9bcac6a6 --- /dev/null +++ b/src/app/nachclearing/mandates/mandates.component.html @@ -0,0 +1,158 @@ + + +
+
+

NACH Mandates

+ +
+ + @if (showForm) { +
+
+ + Account Type + + Savings + Current + + + + Account Id + + + + Mandate Type + + Debit (EMI / SIP) + Credit (Salary / Subsidy) + + +
+
+ + Amount + + + + Max Amount + + + + Frequency + + Monthly + Quarterly + Yearly + As presented + + +
+
+ + Start Date + + + + + + End Date + + + + + + Sponsor Bank Id + + +
+
+ + Debtor / Beneficiary Name + + + + Purpose + + +
+
+ + +
+
+ } + +
+ @for (s of ['ACTIVE', 'CANCELLED', 'EXPIRED']; track s) { + + } +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ m.id }}Reference{{ m.mandateReference }}Account + {{ m.accountNo || m.accountId }} ({{ m.accountType }}) + Type{{ m.mandateType }}Amount + {{ m.amount }} / max {{ m.maxAmount }} + Validity + {{ m.startDate | date: 'mediumDate' }} – {{ m.endDate ? (m.endDate | date: 'mediumDate') : '…' }} + Status + {{ m.status }} + + @if (m.status === 'ACTIVE') { + + } +
+ +
+
diff --git a/src/app/nachclearing/mandates/mandates.component.scss b/src/app/nachclearing/mandates/mandates.component.scss new file mode 100644 index 000000000..d27d83d45 --- /dev/null +++ b/src/app/nachclearing/mandates/mandates.component.scss @@ -0,0 +1,87 @@ +/** + * 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/. + */ + +.page { + padding: 1.5rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.capture-form { + padding: 1rem; + margin-bottom: 1rem; + border-radius: 4px; + + .row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 220px; + } + } + + .actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } +} + +.filters { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + + button.active { + background: #3f51b5; + color: #fff; + } +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; +} + +.muted { + color: rgb(0 0 0 / 50%); + font-size: 0.8rem; +} + +.pill { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + + &.ok { + background: #e6f4ea; + color: #1e7e34; + } + + &.bad { + background: #fdecea; + color: #c62828; + } + + &.neutral { + background: #eceff1; + color: #546e7a; + } +} diff --git a/src/app/nachclearing/mandates/mandates.component.ts b/src/app/nachclearing/mandates/mandates.component.ts new file mode 100644 index 000000000..1af3dce2d --- /dev/null +++ b/src/app/nachclearing/mandates/mandates.component.ts @@ -0,0 +1,179 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { formatDate } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { NachClearingService } from '../nachclearing.service'; +import { pillClass, clearingError } from '../../clearing-network/clearing-status'; + +/** NACH mandates — the standing authorizations that let recurring debits/credits run against an account. */ +@Component({ + selector: 'mifosx-nach-mandates', + templateUrl: './mandates.component.html', + styleUrls: ['./mandates.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIconModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatSnackBarModule, + FaIconComponent + ] +}) +export class MandatesComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private nachService = inject(NachClearingService); + private snackBar = inject(MatSnackBar); + + pillClass = pillClass; + + mandates: any[] = []; + totalRecords = 0; + pageSize = 10; + pageIndex = 0; + pageSizeOptions = [ + 10, + 25, + 50, + 100 + ]; + statusFilter = ''; + busy = false; + showForm = false; + + form: UntypedFormGroup; + + columns = [ + 'id', + 'mandateReference', + 'account', + 'mandateType', + 'amount', + 'validity', + 'status', + 'actions' + ]; + + constructor() { + this.form = this.formBuilder.group({ + accountType: [ + 'SAVINGS', + Validators.required + ], + accountId: [ + '', + Validators.required + ], + mandateType: [ + 'DEBIT', + Validators.required + ], + amount: [''], + maxAmount: [''], + frequency: ['MONTHLY'], + startDate: [ + '', + Validators.required + ], + endDate: [''], + debtorName: [''], + purpose: [''], + sponsorBankId: [''] + }); + } + + ngOnInit(): void { + this.load(); + } + + private notify(message: string, isError = false): void { + this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 }); + } + + private toApiDate(value: any): string | undefined { + return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined; + } + + load(): void { + this.nachService + .getMandates(undefined, this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize) + .subscribe((res: any) => { + this.mandates = res?.pageItems ?? (Array.isArray(res) ? res : []); + this.totalRecords = Number(res?.totalFilteredRecords ?? this.mandates.length); + }); + } + + filterByStatus(status: string): void { + this.statusFilter = this.statusFilter === status ? '' : status; + this.pageIndex = 0; + this.load(); + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + this.load(); + } + + submit(): void { + if (this.form.invalid || this.busy) { + return; + } + this.busy = true; + const raw = this.form.value; + const payload = { + ...raw, + amount: raw.amount || undefined, + maxAmount: raw.maxAmount || undefined, + sponsorBankId: raw.sponsorBankId || undefined, + startDate: this.toApiDate(raw.startDate), + endDate: this.toApiDate(raw.endDate), + dateFormat: 'yyyy-MM-dd', + locale: 'en' + }; + this.nachService.createMandate(payload).subscribe({ + next: () => { + this.busy = false; + this.showForm = false; + this.form.reset({ accountType: 'SAVINGS', mandateType: 'DEBIT', frequency: 'MONTHLY' }); + this.notify('Mandate registered'); + this.load(); + }, + error: (err: any) => { + this.busy = false; + this.notify(clearingError(err, 'Could not register the mandate'), true); + } + }); + } + + cancel(mandate: any): void { + const reason = window.prompt('Reason for cancelling this mandate?'); + if (!reason) { + return; + } + this.nachService.cancelMandate(mandate.id, { reason }).subscribe({ + next: () => { + this.notify('Mandate cancelled'); + this.load(); + }, + error: (err: any) => this.notify(clearingError(err, 'Could not cancel the mandate'), true) + }); + } +} diff --git a/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.html b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.html new file mode 100644 index 000000000..c9ad19b67 --- /dev/null +++ b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.html @@ -0,0 +1,24 @@ + + +
+

NACH Clearing

+

National Automated Clearing House — recurring, mandate-based bulk debits and credits.

+ +
+ @for (card of cards; track card.title) { + +
+
+

{{ card.title }}

+

{{ card.description }}

+
+
+ } +
+
diff --git a/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.scss b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.scss new file mode 100644 index 000000000..a1071cb74 --- /dev/null +++ b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.scss @@ -0,0 +1,52 @@ +/** + * 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/. + */ + +.dashboard { + padding: 1.5rem; +} + +.subtitle { + color: rgb(0 0 0 / 60%); + margin-bottom: 1.5rem; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.nav-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + cursor: pointer; + transition: box-shadow 0.15s ease-in-out; + + &:hover { + box-shadow: 0 3px 10px rgb(0 0 0 / 18%); + } + + .icon { + font-size: 1.75rem; + color: #3f51b5; + width: 2.5rem; + text-align: center; + } + + h3 { + margin: 0 0 0.25rem; + } + + p { + margin: 0; + color: rgb(0 0 0 / 60%); + font-size: 0.85rem; + } +} diff --git a/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.ts b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.ts new file mode 100644 index 000000000..7cfceffa7 --- /dev/null +++ b/src/app/nachclearing/nachclearing-dashboard/nachclearing-dashboard.component.ts @@ -0,0 +1,55 @@ +/** + * 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 } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Landing page for the NACH clearing module. */ +@Component({ + selector: 'mifosx-nachclearing-dashboard', + templateUrl: './nachclearing-dashboard.component.html', + styleUrls: ['./nachclearing-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatCardModule, + MatIconModule, + FaIconComponent + ] +}) +export class NachClearingDashboardComponent { + cards = [ + { + title: 'Mandates', + description: 'Register and manage recurring debit/credit mandates', + icon: 'money-check', + link: [ + '/nachclearing', + 'mandates' + ] + }, + { + title: 'Batches', + description: 'Submit and process NACH debit/credit batches', + icon: 'exchange', + link: [ + '/nachclearing', + 'batches' + ] + }, + { + title: 'Clearing Network', + description: 'Bank directory, keys and multi-bank routing demo', + icon: 'university', + link: ['/clearing-network'] + } + ]; +} diff --git a/src/app/nachclearing/nachclearing-routing.module.ts b/src/app/nachclearing/nachclearing-routing.module.ts new file mode 100644 index 000000000..8e5a64ee3 --- /dev/null +++ b/src/app/nachclearing/nachclearing-routing.module.ts @@ -0,0 +1,57 @@ +/** + * 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +/** Routing Imports */ +import { Route } from '../core/route/route.service'; + +/** Custom Components */ +import { NachClearingDashboardComponent } from './nachclearing-dashboard/nachclearing-dashboard.component'; +import { MandatesComponent } from './mandates/mandates.component'; +import { BatchesComponent } from './batches/batches.component'; +import { BatchDetailComponent } from './batch-detail/batch-detail.component'; + +/** NACH Clearing Routes */ +const routes: Routes = [ + Route.withShell([ + { + path: 'nachclearing', + data: { title: 'NACH Clearing', breadcrumb: 'NACH Clearing' }, + children: [ + { + path: '', + component: NachClearingDashboardComponent + }, + { + path: 'mandates', + component: MandatesComponent, + data: { title: 'Mandates', breadcrumb: 'Mandates' } + }, + { + path: 'batches', + component: BatchesComponent, + data: { title: 'Batches', breadcrumb: 'Batches' } + }, + { + path: 'batches/:id', + component: BatchDetailComponent, + data: { title: 'Batch', routeParamBreadcrumb: 'id' } + } + ] + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class NachClearingRoutingModule {} diff --git a/src/app/nachclearing/nachclearing.module.ts b/src/app/nachclearing/nachclearing.module.ts new file mode 100644 index 000000000..72d910455 --- /dev/null +++ b/src/app/nachclearing/nachclearing.module.ts @@ -0,0 +1,44 @@ +/** + * 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 { NgModule } from '@angular/core'; + +/** Custom Modules */ +import { SharedModule } from '../shared/shared.module'; +import { DirectivesModule } from '../directives/directives.module'; +import { PipesModule } from 'app/pipes/pipes.module'; +import { NachClearingRoutingModule } from './nachclearing-routing.module'; + +/** Custom Components (standalone) */ +import { NachClearingDashboardComponent } from './nachclearing-dashboard/nachclearing-dashboard.component'; +import { MandatesComponent } from './mandates/mandates.component'; +import { BatchesComponent } from './batches/batches.component'; +import { BatchDetailComponent } from './batch-detail/batch-detail.component'; +import { TxnDecisionDialogComponent } from './batch-detail/txn-decision-dialog/txn-decision-dialog.component'; + +/** + * NACH (National Automated Clearing House) Clearing Module. + * + * e-mandate registration/cancellation and the debit/credit transaction batches with per-transaction + * validation and manual pass/return decisioning. + */ +@NgModule({ + imports: [ + SharedModule, + DirectivesModule, + PipesModule, + NachClearingRoutingModule, + NachClearingDashboardComponent, + MandatesComponent, + BatchesComponent, + BatchDetailComponent, + TxnDecisionDialogComponent + ] +}) +export class NachClearingModule {} diff --git a/src/app/nachclearing/nachclearing.service.ts b/src/app/nachclearing/nachclearing.service.ts new file mode 100644 index 000000000..e1a58f747 --- /dev/null +++ b/src/app/nachclearing/nachclearing.service.ts @@ -0,0 +1,89 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * NACH (National Automated Clearing House) clearing — e-mandate registration/cancellation and the + * debit/credit transaction batches with per-transaction validation and pass/return decisioning. Shared + * settlement/reconciliation/bank calls live in ClearingService. + */ +@Injectable({ + providedIn: 'root' +}) +export class NachClearingService { + private http = inject(HttpClient); + + // ----- Mandates ----- + + /** Server-side paginated — returns { pageItems, totalFilteredRecords }. */ + getMandates(accountId?: string | number, status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (accountId) params = params.set('accountId', accountId); + if (status) params = params.set('status', status); + return this.http.get('/nachclearing/mandates', { params }); + } + + getMandate(id: string | number): Observable { + return this.http.get(`/nachclearing/mandates/${id}`); + } + + createMandate(payload: any): Observable { + return this.http.post('/nachclearing/mandates', payload); + } + + cancelMandate(id: string | number, payload: any): Observable { + return this.http.post(`/nachclearing/mandates/${id}`, payload, { + params: new HttpParams().set('command', 'cancel') + }); + } + + // ----- Batches ----- + + getBatches(status?: string, offset = 0, limit = 20): Observable { + let params = new HttpParams().set('offset', offset).set('limit', limit); + if (status) params = params.set('status', status); + return this.http.get('/nachclearing/batches', { params }); + } + + getBatch(id: string | number): Observable { + return this.http.get(`/nachclearing/batches/${id}`); + } + + /** Sub-list may come back as a raw array OR a page — callers handle both. */ + getBatchTransactions(id: string | number): Observable { + return this.http.get(`/nachclearing/batches/${id}/transactions`); + } + + createBatch(payload: any): Observable { + return this.http.post('/nachclearing/batches', payload); + } + + processBatch(id: string | number): Observable { + return this.http.post(`/nachclearing/batches/${id}`, {}, { params: new HttpParams().set('command', 'process') }); + } + + // ----- Transactions ----- + + getTransaction(id: string | number): Observable { + return this.http.get(`/nachclearing/transactions/${id}`); + } + + passTransaction(id: string | number): Observable { + return this.http.post(`/nachclearing/transactions/${id}`, {}, { params: new HttpParams().set('command', 'pass') }); + } + + returnTransaction(id: string | number, payload: any): Observable { + return this.http.post(`/nachclearing/transactions/${id}`, payload, { + params: new HttpParams().set('command', 'return') + }); + } +} diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 33dd14aee..7025253a3 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -3171,6 +3171,9 @@ "More": "More", "Analytics": "Analytics", "Cheque Book Inventory": "Cheque Book Inventory", + "CTS Clearing": "CTS Clearing", + "NACH Clearing": "NACH Clearing", + "Clearing Network": "Clearing Network", "RBI Circulars": "RBI Circulars & Notifications", "CFCFRMS": "CFCFRMS", "CFCFRMS Dashboard": "CFCFRMS Dashboard", @@ -4491,6 +4494,9 @@ "Add Filters": "Add Filters", "Analytics": "Analytics Dashboard (Apache Superset)", "Cheque Book Inventory": "Cheque Book Inventory — stock, issuance, stop-payment, hotlisting", + "CTS Clearing": "CTS Clearing — inward/outward cheque clearing, pass/return, settlement", + "NACH Clearing": "NACH Clearing — mandate-based recurring debit/credit clearing", + "Clearing Network": "Clearing Network — bank directory, keys and multi-bank routing demo", "RBI Circulars": "RBI Circulars & Notifications", "CFCFRMS Dashboard": "CFCFRMS Cyber Fraud Complaint Dashboard", "Complaints": "Complaint Ticket Management", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index fdd9656f0..087e4f2b6 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260707', - 'hash': '8a1b7d5d' + 'version': '260720', + 'hash': 'ed2a40a8' }, 'allow_switching_backend_instance': true };