feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
- CTS clearing module: inward batches (validate/pass/return), outward presentment capture, positive pay, settlement, reconciliation - Outward Clearing merged into one screen: Pending Cheques + Batches tabs (a real clearing session per NPCI submission, either the 2-hourly job or manual send), drill into a batch to settle/dishonour its cheques - Date-range, text, and quick (Today/Yesterday/All) filters with pagination across inward batches, outward presentments, outward batches, and batch detail - NACH clearing module (mandates, batches, transaction decisioning) - Shell/sidenav wiring for both new modules Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed2a40a8bf
commit
6d205c0ed2
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.get(`/clearing/settlement-batches/${id}`);
|
||||
}
|
||||
|
||||
/** payload: { clearingType, sourceBatchId }. */
|
||||
prepareSettlement(payload: any): Observable<any> {
|
||||
return this.http.post('/clearing/settlement-batches', payload);
|
||||
}
|
||||
|
||||
postSettlement(id: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/clearing/settlement-batches/${id}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'post') }
|
||||
);
|
||||
}
|
||||
|
||||
rejectSettlement(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'reject')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Reconciliations -----
|
||||
|
||||
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
|
||||
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<any> {
|
||||
return this.http.post('/clearing/reconciliations', payload);
|
||||
}
|
||||
}
|
||||
@@ -991,14 +991,24 @@
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cheque Honoured
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
|
||||
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="ban" class="menu-item-icon"></fa-icon> Cheque Book Stopped
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
|
||||
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
|
||||
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
|
||||
|
||||
@@ -247,6 +247,32 @@
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.Cheque Book Inventory' | translate }}</a>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
[routerLink]="['/ctsclearing']"
|
||||
[matTooltipPosition]="tooltipPosition"
|
||||
matTooltip="{{ 'tooltips.CTS Clearing' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'MENU_CLEARING'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="exchange" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.CTS Clearing' | translate }}</a>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
[routerLink]="['/nachclearing']"
|
||||
[matTooltipPosition]="tooltipPosition"
|
||||
matTooltip="{{ 'tooltips.NACH Clearing' | translate }}"
|
||||
routerLinkActive="active-menu"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
*mifosxHasPermission="'MENU_CLEARING'"
|
||||
>
|
||||
<mat-icon matListIcon>
|
||||
<fa-icon icon="money-check" size="sm"></fa-icon>
|
||||
</mat-icon>
|
||||
<a matLine>{{ 'labels.menus.NACH Clearing' | translate }}</a>
|
||||
</mat-list-item>
|
||||
<mat-list-item
|
||||
[routerLink]="['/rbi-circulars']"
|
||||
[matTooltipPosition]="tooltipPosition"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.CTS Clearing' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
@for (screen of screens; track screen.label) {
|
||||
<mat-card class="cb-card" [routerLink]="screen.route">
|
||||
<mat-card-content>
|
||||
<div class="card-head">
|
||||
<span class="card-icon">
|
||||
<fa-icon [icon]="screen.icon"></fa-icon>
|
||||
</span>
|
||||
<span class="card-title">{{ screen.label | translate }}</span>
|
||||
</div>
|
||||
<p class="card-desc">{{ screen.description | translate }}</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.get(`/ctsclearing/inward-batches/${id}/items`);
|
||||
}
|
||||
|
||||
createInwardBatch(payload: any): Observable<any> {
|
||||
return this.http.post('/ctsclearing/inward-batches', payload);
|
||||
}
|
||||
|
||||
validateInwardBatch(id: string | number): Observable<any> {
|
||||
return this.http.post(
|
||||
`/ctsclearing/inward-batches/${id}`,
|
||||
{},
|
||||
{ params: new HttpParams().set('command', 'validate') }
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Inward items -----
|
||||
|
||||
getInwardItem(id: string | number): Observable<any> {
|
||||
return this.http.get(`/ctsclearing/inward-items/${id}`);
|
||||
}
|
||||
|
||||
passInwardItem(id: string | number, payload: any): Observable<any> {
|
||||
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<any> {
|
||||
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<string> {
|
||||
return this.http.get(`/ctsclearing/inward-items/${id}/image`, {
|
||||
params: new HttpParams().set('side', side),
|
||||
responseType: 'text'
|
||||
});
|
||||
}
|
||||
|
||||
returnInwardItem(id: string | number, payload: any): Observable<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.get(`/ctsclearing/outward-presentments/${id}`);
|
||||
}
|
||||
|
||||
createOutwardPresentment(payload: any): Observable<any> {
|
||||
return this.http.post('/ctsclearing/outward-presentments', payload);
|
||||
}
|
||||
|
||||
/** command: 'send' | 'settle' | 'dishonour'. Dishonour carries { dishonourReasonCode }. */
|
||||
actOnOutwardPresentment(id: string | number, command: string, payload: any = {}): Observable<any> {
|
||||
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<any> {
|
||||
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<string> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.post('/ctsclearing/positive-pay', payload);
|
||||
}
|
||||
|
||||
cancelPositivePay(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/ctsclearing/positive-pay/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'cancel')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Inward Batch' | translate }} #{{ batch.id }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing', 'inward']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="info-card">
|
||||
<mat-card-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Batch Reference' | translate }}</span
|
||||
><span>{{ batch.batchReference }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Session Date' | translate }}</span
|
||||
><span>{{ batch.sessionDate ? (batch.sessionDate | dateFormat) : '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Presentment Type' | translate }}</span
|
||||
><span>{{ batch.presentmentType }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Items' | translate }}</span
|
||||
><span>{{ batch.itemCount ?? batch.totalCount ?? items.length }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Total Amount' | translate }}</span
|
||||
><span>{{ batch.totalAmount ?? '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Status' | translate }}</span>
|
||||
<span
|
||||
><span class="pill" [ngClass]="pillClass(batch.status)">{{ batch.status }}</span></span
|
||||
>
|
||||
</div>
|
||||
@if (batch.presentingBankName || batch.presentingBankCode) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Presenting Bank' | translate }}</span
|
||||
><span
|
||||
>{{ batch.presentingBankName || '–' }} <span class="muted">{{ batch.presentingBankCode }}</span></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@if (batch.returnTatDeadline) {
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Return TAT Deadline' | translate }}</span
|
||||
><span>{{ batch.returnTatDeadline | dateFormat }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<div class="action-toolbar">
|
||||
<button mat-raised-button color="primary" (click)="runValidation()" [disabled]="busy">
|
||||
<fa-icon icon="check-circle"></fa-icon> {{ 'labels.buttons.Run Validation' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button (click)="prepareSettlement()" [disabled]="busy">
|
||||
<fa-icon icon="money-check"></fa-icon> {{ 'labels.buttons.Prepare Settlement' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="items">
|
||||
<ng-container matColumnDef="chequeNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
{{ it.chequeNumber }}
|
||||
@if (it.instrumentStopPaymentFlag) {
|
||||
<span class="pill bad" matTooltip="Stop payment was flagged on the depositing side">STOP</span>
|
||||
}
|
||||
@if (it.micrCode) {
|
||||
<span class="muted">{{ it.micrCode }}</span>
|
||||
}
|
||||
@if (it.ifsc) {
|
||||
<span class="muted">{{ it.ifsc }}</span>
|
||||
}
|
||||
@if (it.ctsReferenceNumber) {
|
||||
<span class="muted">{{ it.ctsReferenceNumber }}</span>
|
||||
}
|
||||
@if (it.instrumentDate) {
|
||||
<span class="muted">Dated {{ it.instrumentDate | dateFormat }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="drawer">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
{{ it.accountNo || it.accountId || '–' }}
|
||||
@if (it.clientName) {
|
||||
<span class="muted">{{ it.clientName }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="payeeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">{{ it.payeeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it" class="num">
|
||||
{{ it.amount }}
|
||||
@if (it.amountInWords) {
|
||||
<div class="muted">{{ it.amountInWords }}</div>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="image">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
<div class="image-cell">
|
||||
@if (getItemImageUrl(it, 'front')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(it, 'front')"
|
||||
class="cheque-thumb"
|
||||
alt="front"
|
||||
matTooltip="Front image — click to zoom"
|
||||
(click)="openImageZoom(it, 'front')"
|
||||
/>
|
||||
} @else {
|
||||
<input #frontFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'front', $event)" />
|
||||
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
@if (getItemImageUrl(it, 'back')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(it, 'back')"
|
||||
class="cheque-thumb"
|
||||
alt="back"
|
||||
matTooltip="Back image — click to zoom"
|
||||
(click)="openImageZoom(it, 'back')"
|
||||
/>
|
||||
} @else {
|
||||
<input #backFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'back', $event)" />
|
||||
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="checks">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Validation' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
<div class="check-badges">
|
||||
@for (rule of rules; track rule.key) {
|
||||
<span class="pill" [ngClass]="ruleClass(it, rule)" [title]="rule.label">{{ rule.label }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (it.validationDetail) {
|
||||
<div class="validation-detail muted">{{ it.validationDetail }}</div>
|
||||
}
|
||||
@if (it.iqaStatus && it.iqaStatus !== 'NA') {
|
||||
<span class="pill" [ngClass]="pillClass(it.iqaStatus)" matTooltip="Image Quality Assessment"
|
||||
>IQA: {{ it.iqaStatus }}</span
|
||||
>
|
||||
}
|
||||
@if (it.iqaDetail) {
|
||||
<div class="muted">{{ it.iqaDetail }}</div>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="decision">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Decision' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
@if (it.decision) {
|
||||
<span class="pill" [ngClass]="pillClass(it.decision)">{{ it.decision }}</span>
|
||||
} @else {
|
||||
<span class="pill neutral">{{ it.itemStatus || 'PENDING' }}</span>
|
||||
}
|
||||
@if (it.returnReasonCode) {
|
||||
<div class="muted">{{ it.returnReasonCode }}</div>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="hold">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Hold' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it" class="num">
|
||||
@if (it.holdPlaced) {
|
||||
{{ it.holdAmount ?? '✓' }}
|
||||
} @else {
|
||||
–
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="returnDeadline">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Return Deadline' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
{{ it.returnDeadline ? (it.returnDeadline | dateFormat) : '–' }}
|
||||
@if (it.originalSerialNumber) {
|
||||
<span class="muted">Orig # {{ it.originalSerialNumber }}</span>
|
||||
}
|
||||
@if (it.depositDate) {
|
||||
<span class="muted">Deposited {{ it.depositDate | dateFormat }}</span>
|
||||
}
|
||||
@if (it.presentingBranchCode) {
|
||||
<span class="muted">Branch {{ it.presentingBranchCode }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let it">
|
||||
<button mat-button color="primary" (click)="decide(it)">{{ 'labels.buttons.Decide' | translate }}</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (items.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>;
|
||||
private lightboxInstance: LightGallery | null = null;
|
||||
private readonly lightboxPlugins = [
|
||||
lgZoom,
|
||||
lgFullscreen
|
||||
];
|
||||
|
||||
pillClass = pillClass;
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
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 "<itemId>_<side>" — 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: `<div class="lg-caption"><h4>Front — Cheque ${item.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (backUrl) {
|
||||
items.push({
|
||||
src: backUrl,
|
||||
thumb: backUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${item.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const startIndex = side === 'back' && frontUrl ? 1 : 0;
|
||||
this.destroyLightbox();
|
||||
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||
dynamic: true,
|
||||
dynamicEl: items,
|
||||
plugins: this.lightboxPlugins,
|
||||
download: false,
|
||||
closable: true,
|
||||
escKey: true,
|
||||
zoomFromOrigin: true
|
||||
});
|
||||
this.lightboxInstance.openGallery(startIndex);
|
||||
}
|
||||
|
||||
private destroyLightbox(): void {
|
||||
if (this.lightboxInstance) {
|
||||
this.lightboxInstance.destroy();
|
||||
this.lightboxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
uploadItemImage(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+77
@@ -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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ 'labels.inputs.Cheque' | translate }} {{ item.chequeNumber }}</h1>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Payee Name' | translate }}</span
|
||||
><span>{{ item.payeeName || '–' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Amount' | translate }}</span
|
||||
><span>{{ item.amount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
|
||||
><span>{{ item.accountNo || item.accountId || '–' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-toggle">
|
||||
<button mat-stroked-button type="button" [class.active]="mode === 'PASS'" color="primary" (click)="setMode('PASS')">
|
||||
{{ 'labels.buttons.Pass' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[class.active]="mode === 'RETURN'"
|
||||
color="warn"
|
||||
(click)="setMode('RETURN')"
|
||||
>
|
||||
{{ 'labels.buttons.Return' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (mode === 'PASS') {
|
||||
<form [formGroup]="passForm">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||
<input matInput formControlName="note" />
|
||||
</mat-form-field>
|
||||
</form>
|
||||
} @else {
|
||||
<form [formGroup]="returnForm">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Return Reason Code' | translate }}</mat-label>
|
||||
<mat-select formControlName="returnReasonCode" required>
|
||||
@for (c of reasonCodes; track c.id) {
|
||||
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="reason" />
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div mat-dialog-actions align="end">
|
||||
<button mat-raised-button type="button" mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="mode === 'RETURN' ? 'warn' : 'primary'"
|
||||
(click)="submit()"
|
||||
[disabled]="busy || (mode === 'RETURN' && returnForm.invalid)"
|
||||
>
|
||||
{{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
+41
@@ -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%;
|
||||
}
|
||||
+128
@@ -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<ItemDecisionDialogComponent>>(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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.Inward Clearing' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Receive Batch' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!showReceiveForm) {
|
||||
<div class="filter-row">
|
||||
@for (s of ['RECEIVED', 'VALIDATED', 'SETTLED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<button mat-stroked-button [class.active]="quickFilterMode === 'today'" (click)="setQuickFilter('today')">
|
||||
Today
|
||||
</button>
|
||||
<button mat-stroked-button [class.active]="quickFilterMode === 'yesterday'" (click)="setQuickFilter('yesterday')">
|
||||
Yesterday
|
||||
</button>
|
||||
<button mat-stroked-button [class.active]="quickFilterMode === 'all'" (click)="setQuickFilter('all')">All</button>
|
||||
</div>
|
||||
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="fromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="toPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
|
||||
<input matInput formControlName="batchReference" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="presentingBankName" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="clearFilters()">
|
||||
{{ 'labels.buttons.Clear' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (showReceiveForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
|
||||
<input matInput formControlName="batchReference" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Session Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="sessionPicker" formControlName="sessionDate" required />
|
||||
<mat-datepicker-toggle matSuffix [for]="sessionPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #sessionPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presentment Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="presentmentType" required>
|
||||
<mat-option value="REGULAR">{{ 'labels.inputs.Regular' | translate }}</mat-option>
|
||||
<mat-option value="EXPRESS">{{ 'labels.inputs.Express' | translate }}</mat-option>
|
||||
<mat-option value="HIGH_VALUE">{{ 'labels.inputs.High Value' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="presentingBankName" />
|
||||
<mat-hint>{{ 'labels.text.Bank that presented this batch to us' | translate }}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presenting Bank Code' | translate }}</mat-label>
|
||||
<input matInput formControlName="presentingBankCode" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">{{ 'labels.inputs.Cheques' | translate }}</h3>
|
||||
<div formArrayName="items" class="item-rows">
|
||||
@for (row of items.controls; track row; let i = $index) {
|
||||
<div class="item-row" [formGroupName]="i">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="chequeNumber" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.MICR Code' | translate }}</mat-label>
|
||||
<input matInput formControlName="micrCode" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.IFSC' | translate }}</mat-label>
|
||||
<input matInput formControlName="ifsc" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="payeeName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="amount" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Amount In Words' | translate }}</mat-label>
|
||||
<input matInput formControlName="amountInWords" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Instrument Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="instrPicker" formControlName="instrumentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="instrPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #instrPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presented Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="presPicker" formControlName="presentedDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="presPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #presPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="accountType">
|
||||
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
|
||||
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Account Id' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="accountId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Original Serial No' | translate }}</mat-label>
|
||||
<input matInput formControlName="originalSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Presenting Branch Code' | translate }}</mat-label>
|
||||
<input matInput formControlName="presentingBranchCode" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
class="row-remove"
|
||||
(click)="removeItem(i)"
|
||||
[disabled]="items.length === 1"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button mat-stroked-button type="button" (click)="addItem()">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Add Cheque' | translate }}
|
||||
</button>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showReceiveForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="receiveForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (!showReceiveForm) {
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="batches">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="batchReference">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="sessionDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Session Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
{{ b.sessionDate | dateFormat }}
|
||||
@if (b.receivedOn) {
|
||||
<span class="muted">{{ b.receivedOn | dateFormat: 'hh:mm A' }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="presentmentType">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Presentment Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.presentmentType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="items">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? b.totalCount ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
|
||||
<button mat-button color="primary" (click)="open(b)">{{ 'labels.buttons.View' | translate }}</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns" (click)="open(row)" class="clickable-row"></tr>
|
||||
</table>
|
||||
@if (batches.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.inputs.Batch Reference' | translate }}: {{ batchReference }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing', 'outward']">
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of ['SENT_TO_CLEARING_HOUSE', 'SETTLED', 'DISHONOURED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="chequeNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="drawerBankName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="payeeName" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="clearFilters()">
|
||||
{{ 'labels.buttons.Clear' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="presentments">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="chequeNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="drawerBank">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
{{ p.drawerBankName || '–' }}
|
||||
@if (p.drawerIfsc) {
|
||||
<span class="muted">{{ p.drawerIfsc }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="payeeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="image">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<div class="image-cell">
|
||||
@if (getItemImageUrl(p, 'front')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(p, 'front')"
|
||||
class="cheque-thumb"
|
||||
alt="front"
|
||||
matTooltip="Front image — click to zoom"
|
||||
(click)="openImageZoom(p, 'front')"
|
||||
/>
|
||||
}
|
||||
@if (getItemImageUrl(p, 'back')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(p, 'back')"
|
||||
class="cheque-thumb"
|
||||
alt="back"
|
||||
matTooltip="Back image — click to zoom"
|
||||
(click)="openImageZoom(p, 'back')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
|
||||
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
|
||||
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
|
||||
>IQA: {{ p.iqaStatus }}</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
@if (p.status === 'SENT_TO_CLEARING_HOUSE') {
|
||||
<button mat-button color="primary" (click)="act(p, 'settle')">
|
||||
{{ 'labels.buttons.Settle' | translate }}
|
||||
</button>
|
||||
<button mat-button color="warn" (click)="toggleDishonour(p)">
|
||||
{{ 'labels.buttons.Dishonour' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (p.ctsFileJson) {
|
||||
<button mat-button color="primary" (click)="toggleEnvelope(p)">
|
||||
{{ shownEnvelopeId === p.id ? 'Hide' : 'View' }} CTS file
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (presentments.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@for (p of presentments; track p.id) {
|
||||
@if (shownEnvelopeId === p.id && p.ctsFileJson) {
|
||||
<div class="envelope-panel">
|
||||
<div class="envelope-head">
|
||||
📄 CTS-2010 outward file sent to the clearing house — cheque #{{ p.chequeNumber }}
|
||||
</div>
|
||||
<pre class="cts-file">{{ prettyJson(p.ctsFileJson) }}</pre>
|
||||
@if (p.npciResponseJson) {
|
||||
<div class="envelope-head">📥 NPCI's real routing response for this batch:</div>
|
||||
<pre class="cts-file">{{ prettyJson(p.npciResponseJson) }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (dishonourForId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Dishonour Cheque' | translate }}</h3>
|
||||
<form [formGroup]="dishonourForm" (ngSubmit)="submitDishonour(dishonourForId)">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Dishonour Reason Code' | translate }}</mat-label>
|
||||
<mat-select formControlName="dishonourReasonCode" required>
|
||||
@for (c of reasonCodes; track c.id) {
|
||||
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="dishonourForId = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="dishonourForm.invalid || busy">
|
||||
{{ 'labels.buttons.Dishonour' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>;
|
||||
private lightboxInstance: LightGallery | null = null;
|
||||
private readonly lightboxPlugins = [
|
||||
lgZoom,
|
||||
lgFullscreen
|
||||
];
|
||||
|
||||
pillClass = pillClass;
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
batchReference!: string;
|
||||
presentments: any[] = [];
|
||||
busy = false;
|
||||
dishonourForId: number | null = null;
|
||||
shownEnvelopeId: number | null = null;
|
||||
reasonCodes: any[] = [];
|
||||
|
||||
totalRecords = 0;
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
statusFilter = '';
|
||||
|
||||
dishonourForm: UntypedFormGroup;
|
||||
filterForm: UntypedFormGroup;
|
||||
|
||||
columns = [
|
||||
'id',
|
||||
'chequeNumber',
|
||||
'drawerBank',
|
||||
'payeeName',
|
||||
'amount',
|
||||
'image',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.dishonourForm = this.formBuilder.group({
|
||||
dishonourReasonCode: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
this.filterForm = this.formBuilder.group({
|
||||
chequeNumber: [''],
|
||||
drawerBankName: [''],
|
||||
payeeName: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.batchReference = this.route.snapshot.paramMap.get('id') as string;
|
||||
this.loadAll();
|
||||
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
|
||||
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.reasonCodes = list.filter((c: any) => c.active !== false);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyLightbox();
|
||||
}
|
||||
|
||||
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
|
||||
imageUrls: { [key: string]: string } = {};
|
||||
|
||||
loadAll(): void {
|
||||
const f = this.filterForm.value;
|
||||
const filters = {
|
||||
batchReference: this.batchReference,
|
||||
chequeNumber: f.chequeNumber || undefined,
|
||||
drawerBankName: f.drawerBankName || undefined,
|
||||
payeeName: f.payeeName || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardPresentments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
|
||||
this.presentments.forEach((p: any) => {
|
||||
this.loadItemImage(p, 'front');
|
||||
this.loadItemImage(p, 'back');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
filterByStatus(status: string): void {
|
||||
this.statusFilter = this.statusFilter === status ? '' : status;
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterForm.reset({ chequeNumber: '', drawerBankName: '', payeeName: '' });
|
||||
this.pageIndex = 0;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
|
||||
loadItemImage(presentment: any, side: 'front' | 'back'): void {
|
||||
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
|
||||
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
|
||||
error: () => {
|
||||
/* no image uploaded for this side yet */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
|
||||
return this.imageUrls[`${presentment.id}_${side}`] || null;
|
||||
}
|
||||
|
||||
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
|
||||
openImageZoom(presentment: any, side: 'front' | 'back'): void {
|
||||
const frontUrl = this.getItemImageUrl(presentment, 'front');
|
||||
const backUrl = this.getItemImageUrl(presentment, 'back');
|
||||
const items: GalleryItem[] = [];
|
||||
if (frontUrl) {
|
||||
items.push({
|
||||
src: frontUrl,
|
||||
thumb: frontUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (backUrl) {
|
||||
items.push({
|
||||
src: backUrl,
|
||||
thumb: backUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const startIndex = side === 'back' && frontUrl ? 1 : 0;
|
||||
this.destroyLightbox();
|
||||
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||
dynamic: true,
|
||||
dynamicEl: items,
|
||||
plugins: this.lightboxPlugins,
|
||||
download: false,
|
||||
closable: true,
|
||||
escKey: true,
|
||||
zoomFromOrigin: true
|
||||
});
|
||||
this.lightboxInstance.openGallery(startIndex);
|
||||
}
|
||||
|
||||
private destroyLightbox(): void {
|
||||
if (this.lightboxInstance) {
|
||||
this.lightboxInstance.destroy();
|
||||
this.lightboxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
act(presentment: any, command: string): void {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const messages: any = {
|
||||
settle: 'Presentment settled',
|
||||
dishonour: 'Presentment dishonoured'
|
||||
};
|
||||
this.ctsService.actOnOutwardPresentment(presentment.id, command, { locale: 'en' }).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.notify(messages[command] || 'Done');
|
||||
this.loadAll();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Action failed'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDishonour(presentment: any): void {
|
||||
this.dishonourForId = this.dishonourForId === presentment.id ? null : presentment.id;
|
||||
this.dishonourForm.reset();
|
||||
}
|
||||
|
||||
submitDishonour(id: number): void {
|
||||
if (this.dishonourForm.invalid || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const payload = { dishonourReasonCode: this.dishonourForm.value.dishonourReasonCode, locale: 'en' };
|
||||
this.ctsService.actOnOutwardPresentment(id, 'dishonour', payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.dishonourForId = null;
|
||||
this.notify('Presentment dishonoured');
|
||||
this.loadAll();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Dishonour failed'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleEnvelope(presentment: any): void {
|
||||
this.shownEnvelopeId = this.shownEnvelopeId === presentment.id ? null : presentment.id;
|
||||
}
|
||||
|
||||
prettyJson(value: string): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.Outward Clearing' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showCaptureForm = !showCaptureForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Capture Cheque' | translate }}
|
||||
</button>
|
||||
@if (showCaptureForm) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="fillDummyData()"
|
||||
matTooltip="Auto-fill the capture form with sample values"
|
||||
>
|
||||
<fa-icon icon="magic"></fa-icon> Fill Dummy Data
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="busy"
|
||||
(click)="sendPendingBatch()"
|
||||
matTooltip="Bundles every CAPTURED cheque into one CTS file and sends it to the clearing house — stands in for the (disabled) clearing-session scheduler"
|
||||
>
|
||||
<fa-icon icon="exchange"></fa-icon> Send Pending Batch
|
||||
</button>
|
||||
@if (showCaptureForm) {
|
||||
<button mat-raised-button (click)="cancelCapture()">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
} @else {
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showCaptureForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="captureForm" (ngSubmit)="submitCapture()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Depositing Account Type' | translate }}</mat-label>
|
||||
<mat-select formControlName="depositingAccountType" required>
|
||||
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
|
||||
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Depositing Account Id' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="depositingAccountId" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="chequeNumber" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="amount" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="payeeName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="drawerBankName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Drawer Bank MICR' | translate }}</mat-label>
|
||||
<input matInput formControlName="drawerBankMicr" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Drawer IFSC' | translate }}</mat-label>
|
||||
<input matInput formControlName="drawerIfsc" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Drawer Account Number</mat-label>
|
||||
<input matInput formControlName="drawerAccountNumber" placeholder="account debited at the drawee bank" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Deposited Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="depPicker" formControlName="depositedDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="depPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #depPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>IQA Status</mat-label>
|
||||
<mat-select formControlName="iqaStatus">
|
||||
<mat-option value="PASS">PASS</mat-option>
|
||||
<mat-option value="FAIL">FAIL</mat-option>
|
||||
<mat-option value="PENDING">PENDING</mat-option>
|
||||
<mat-option value="NA">NA</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>IQA Detail</mat-label>
|
||||
<input matInput formControlName="iqaDetail" placeholder="optional notes" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="image-cell">
|
||||
@if (frontPreviewUrl) {
|
||||
<img [src]="frontPreviewUrl" class="cheque-thumb" alt="front" matTooltip="Front image" />
|
||||
}
|
||||
@if (backPreviewUrl) {
|
||||
<img [src]="backPreviewUrl" class="cheque-thumb" alt="back" matTooltip="Back image" />
|
||||
}
|
||||
<input #frontFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('front', $event)" />
|
||||
<input #backFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('back', $event)" />
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[color]="selectedFrontFile ? '' : 'warn'"
|
||||
(click)="frontFile.click()"
|
||||
>
|
||||
<fa-icon icon="plus"></fa-icon> {{
|
||||
selectedFrontFile ? selectedFrontFile.name : 'Front Cheque Image *'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[color]="selectedBackFile ? '' : 'warn'"
|
||||
(click)="backFile.click()"
|
||||
>
|
||||
<fa-icon icon="plus"></fa-icon> {{
|
||||
selectedBackFile ? selectedBackFile.name : 'Back Cheque Image *'
|
||||
}}
|
||||
</button>
|
||||
@if (!selectedFrontFile || !selectedBackFile) {
|
||||
<span class="muted">Both front & back images are required</span>
|
||||
}
|
||||
</div>
|
||||
<div class="stop-payment-row">
|
||||
<mat-checkbox formControlName="stopPaymentFlag">Stop Payment on this cheque</mat-checkbox>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="cancelCapture()">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmitCapture()">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (!showCaptureForm) {
|
||||
<mat-tab-group class="outward-tabs" animationDuration="0ms">
|
||||
<mat-tab label="Pending Cheques">
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="pendingFilterForm" (ngSubmit)="applyPendingFilters()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="pendingFromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="pendingFromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #pendingFromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="pendingToPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="pendingToPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #pendingToPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
|
||||
<input matInput formControlName="chequeNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="drawerBankName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
|
||||
<input matInput formControlName="payeeName" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="clearPendingFilters()">
|
||||
{{ 'labels.buttons.Clear' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit">
|
||||
{{ 'labels.buttons.Filter' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="presentments">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="chequeNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="drawerBank">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
{{ p.drawerBankName || '–' }}
|
||||
@if (p.drawerIfsc) {
|
||||
<span class="muted">{{ p.drawerIfsc }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="payeeName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="depositedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Deposited Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
{{ p.depositedDate ? (p.depositedDate | dateFormat) : '–' }}
|
||||
@if (p.createdOn) {
|
||||
<span class="muted">{{ p.createdOn | dateFormat: 'hh:mm A' }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="image">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<div class="image-cell">
|
||||
@if (getItemImageUrl(p, 'front')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(p, 'front')"
|
||||
class="cheque-thumb"
|
||||
alt="front"
|
||||
matTooltip="Front image — click to zoom"
|
||||
(click)="openImageZoom(p, 'front')"
|
||||
/>
|
||||
} @else {
|
||||
<input
|
||||
#frontFile
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
(change)="uploadItemImage(p, 'front', $event)"
|
||||
/>
|
||||
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
@if (getItemImageUrl(p, 'back')) {
|
||||
<img
|
||||
[src]="getItemImageUrl(p, 'back')"
|
||||
class="cheque-thumb"
|
||||
alt="back"
|
||||
matTooltip="Back image — click to zoom"
|
||||
(click)="openImageZoom(p, 'back')"
|
||||
/>
|
||||
} @else {
|
||||
<input
|
||||
#backFile
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
(change)="uploadItemImage(p, 'back', $event)"
|
||||
/>
|
||||
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
|
||||
@if (p.stopPaymentFlag) {
|
||||
<span class="pill bad" matTooltip="Stop payment marked on this cheque">STOP</span>
|
||||
}
|
||||
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
|
||||
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
|
||||
>IQA: {{ p.iqaStatus }}</span
|
||||
>
|
||||
}
|
||||
@if (getItemImageUrl(p, 'front') && getItemImageUrl(p, 'back')) {
|
||||
<span
|
||||
class="muted"
|
||||
matTooltip="Goes out automatically with the next 2-hourly clearing session, or via Send Pending Batch"
|
||||
>
|
||||
Awaiting next clearing session
|
||||
</span>
|
||||
} @else {
|
||||
<span class="muted" matTooltip="Upload both front & back cheque image before it can be sent"
|
||||
>Image required</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="pendingColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: pendingColumns"></tr>
|
||||
</table>
|
||||
@if (presentments.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (pendingTotalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="pendingTotalRecords"
|
||||
[pageSize]="pendingPageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pendingPageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPendingPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
<mat-tab label="Batches">
|
||||
<div class="filter-row">
|
||||
<button mat-stroked-button [class.active]="batchDateFilter === 'today'" (click)="setBatchDateFilter('today')">
|
||||
Today
|
||||
</button>
|
||||
<button mat-stroked-button [class.active]="batchDateFilter === 'all'" (click)="setBatchDateFilter('all')">
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="batchesFilterForm" (ngSubmit)="applyBatchesFilters()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="batchFromPicker" formControlName="fromDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="batchFromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #batchFromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="batchToPicker" formControlName="toDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="batchToPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #batchToPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
|
||||
<input matInput formControlName="batchReference" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="clearBatchesFilters()">
|
||||
{{ 'labels.buttons.Clear' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit">
|
||||
{{ 'labels.buttons.Filter' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="batches">
|
||||
<ng-container matColumnDef="batchReference">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="session">
|
||||
<th mat-header-cell *matHeaderCellDef>Session</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
{{ b.sessionStartedOn | dateFormat: 'DD MMM YYYY, hh:mm A' }}
|
||||
@if (b.sessionEndedOn) {
|
||||
<span class="muted">to {{ b.sessionEndedOn | dateFormat: 'hh:mm A' }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="items">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Total Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="progress">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
@if (b.settledCount) {
|
||||
<span class="pill ok">{{ b.settledCount }} settled</span>
|
||||
}
|
||||
@if (b.dishonouredCount) {
|
||||
<span class="pill bad">{{ b.dishonouredCount }} dishonoured</span>
|
||||
}
|
||||
@if (b.sentCount) {
|
||||
<span class="pill neutral">{{ b.sentCount }} awaiting settlement</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
|
||||
<button mat-button color="primary" (click)="openBatch(b)">
|
||||
{{ 'labels.buttons.View' | translate }}
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="batchColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: batchColumns" (click)="openBatch(row)" class="clickable-row"></tr>
|
||||
</table>
|
||||
@if (batches.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (batchesTotalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="batchesTotalRecords"
|
||||
[pageSize]="batchesPageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="batchesPageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onBatchesPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>;
|
||||
private lightboxInstance: LightGallery | null = null;
|
||||
private readonly lightboxPlugins = [
|
||||
lgZoom,
|
||||
lgFullscreen
|
||||
];
|
||||
|
||||
pillClass = pillClass;
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
busy = false;
|
||||
showCaptureForm = false;
|
||||
|
||||
/** Cheque images picked in the capture form itself — uploaded right after creation, in the same Submit. */
|
||||
selectedFrontFile: File | null = null;
|
||||
selectedBackFile: File | null = null;
|
||||
frontPreviewUrl: string | null = null;
|
||||
backPreviewUrl: string | null = null;
|
||||
|
||||
captureForm: UntypedFormGroup;
|
||||
pendingFilterForm: UntypedFormGroup;
|
||||
batchesFilterForm: UntypedFormGroup;
|
||||
|
||||
// ----- Pending cheques (CAPTURED, not yet in any batch) -----
|
||||
|
||||
presentments: any[] = [];
|
||||
pendingTotalRecords = 0;
|
||||
pendingPageSize = 10;
|
||||
pendingPageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
pendingColumns = [
|
||||
'id',
|
||||
'chequeNumber',
|
||||
'drawerBank',
|
||||
'payeeName',
|
||||
'amount',
|
||||
'depositedDate',
|
||||
'image',
|
||||
'status'
|
||||
];
|
||||
|
||||
// ----- Batches (real, already-sent clearing sessions) -----
|
||||
|
||||
batches: any[] = [];
|
||||
batchesTotalRecords = 0;
|
||||
batchesPageSize = 10;
|
||||
batchesPageIndex = 0;
|
||||
|
||||
batchColumns = [
|
||||
'batchReference',
|
||||
'session',
|
||||
'items',
|
||||
'amount',
|
||||
'progress',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults to today on first load so this isn't every session ever sent — "All" widens it. Persisted on
|
||||
* the singleton service so opening a batch and navigating back doesn't silently reset the choice.
|
||||
*/
|
||||
get batchDateFilter(): 'today' | 'all' {
|
||||
return this.ctsService.outwardBatchesDateFilter;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.captureForm = this.formBuilder.group({
|
||||
depositingAccountType: [
|
||||
'SAVINGS',
|
||||
Validators.required
|
||||
],
|
||||
depositingAccountId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
chequeNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
amount: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
payeeName: [''],
|
||||
drawerBankName: [''],
|
||||
drawerBankMicr: [''],
|
||||
drawerIfsc: [''],
|
||||
depositedDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
stopPaymentFlag: [false],
|
||||
iqaStatus: ['PASS'],
|
||||
iqaDetail: [''],
|
||||
drawerAccountNumber: ['']
|
||||
});
|
||||
this.pendingFilterForm = this.formBuilder.group({
|
||||
fromDate: [''],
|
||||
toDate: [''],
|
||||
chequeNumber: [''],
|
||||
drawerBankName: [''],
|
||||
payeeName: ['']
|
||||
});
|
||||
// Batches defaults to today on first load — matches the persisted Today/All quick toggle below.
|
||||
const initialBatchDate = this.batchDateFilter === 'today' ? new Date() : null;
|
||||
this.batchesFilterForm = this.formBuilder.group({
|
||||
fromDate: [initialBatchDate],
|
||||
toDate: [initialBatchDate],
|
||||
batchReference: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPresentments();
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyLightbox();
|
||||
}
|
||||
|
||||
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
|
||||
imageUrls: { [key: string]: string } = {};
|
||||
|
||||
loadPresentments(): void {
|
||||
const f = this.pendingFilterForm.value;
|
||||
const filters = {
|
||||
fromDate: this.toApiDate(f.fromDate),
|
||||
toDate: this.toApiDate(f.toDate),
|
||||
chequeNumber: f.chequeNumber || undefined,
|
||||
drawerBankName: f.drawerBankName || undefined,
|
||||
payeeName: f.payeeName || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardPresentments('CAPTURED', this.pendingPageIndex * this.pendingPageSize, this.pendingPageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.pendingTotalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
|
||||
this.presentments.forEach((p: any) => {
|
||||
this.loadItemImage(p, 'front');
|
||||
this.loadItemImage(p, 'back');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyPendingFilters(): void {
|
||||
this.pendingPageIndex = 0;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
clearPendingFilters(): void {
|
||||
this.pendingFilterForm.reset({ fromDate: '', toDate: '', chequeNumber: '', drawerBankName: '', payeeName: '' });
|
||||
this.pendingPageIndex = 0;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
onPendingPageChange(event: PageEvent): void {
|
||||
this.pendingPageSize = event.pageSize;
|
||||
this.pendingPageIndex = event.pageIndex;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
loadBatches(): void {
|
||||
const f = this.batchesFilterForm.value;
|
||||
const filters = {
|
||||
fromDate: this.toApiDate(f.fromDate),
|
||||
toDate: this.toApiDate(f.toDate),
|
||||
batchReference: f.batchReference || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardBatches(this.batchesPageIndex * this.batchesPageSize, this.batchesPageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.batchesTotalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
|
||||
});
|
||||
}
|
||||
|
||||
applyBatchesFilters(): void {
|
||||
// A manual date edit no longer matches a plain "today"/"all" quick state — treat it as custom.
|
||||
this.ctsService.outwardBatchesDateFilter = 'all';
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
clearBatchesFilters(): void {
|
||||
this.ctsService.outwardBatchesDateFilter = 'all';
|
||||
this.batchesFilterForm.reset({ fromDate: '', toDate: '', batchReference: '' });
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
setBatchDateFilter(filter: 'today' | 'all'): void {
|
||||
this.ctsService.outwardBatchesDateFilter = filter;
|
||||
const day = filter === 'today' ? new Date() : null;
|
||||
this.batchesFilterForm.patchValue({ fromDate: day, toDate: day });
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
onBatchesPageChange(event: PageEvent): void {
|
||||
this.batchesPageSize = event.pageSize;
|
||||
this.batchesPageIndex = event.pageIndex;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
openBatch(batch: any): void {
|
||||
this.router.navigate([
|
||||
'/ctsclearing',
|
||||
'outward-batches',
|
||||
batch.batchReference
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
|
||||
loadItemImage(presentment: any, side: 'front' | 'back'): void {
|
||||
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
|
||||
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
|
||||
error: () => {
|
||||
/* no image uploaded for this side yet */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
|
||||
return this.imageUrls[`${presentment.id}_${side}`] || null;
|
||||
}
|
||||
|
||||
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
|
||||
openImageZoom(presentment: any, side: 'front' | 'back'): void {
|
||||
const frontUrl = this.getItemImageUrl(presentment, 'front');
|
||||
const backUrl = this.getItemImageUrl(presentment, 'back');
|
||||
const items: GalleryItem[] = [];
|
||||
if (frontUrl) {
|
||||
items.push({
|
||||
src: frontUrl,
|
||||
thumb: frontUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (backUrl) {
|
||||
items.push({
|
||||
src: backUrl,
|
||||
thumb: backUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const startIndex = side === 'back' && frontUrl ? 1 : 0;
|
||||
this.destroyLightbox();
|
||||
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||
dynamic: true,
|
||||
dynamicEl: items,
|
||||
plugins: this.lightboxPlugins,
|
||||
download: false,
|
||||
closable: true,
|
||||
escKey: true,
|
||||
zoomFromOrigin: true
|
||||
});
|
||||
this.lightboxInstance.openGallery(startIndex);
|
||||
}
|
||||
|
||||
private destroyLightbox(): void {
|
||||
if (this.lightboxInstance) {
|
||||
this.lightboxInstance.destroy();
|
||||
this.lightboxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
uploadItemImage(presentment: any, side: 'front' | 'back', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.ctsService.uploadOutwardPresentmentImage(presentment.id, side, file).subscribe({
|
||||
next: () => {
|
||||
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
|
||||
this.loadItemImage(presentment, side);
|
||||
},
|
||||
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
private toApiDate(value: any): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return formatDate(value, 'yyyy-MM-dd', 'en');
|
||||
}
|
||||
|
||||
/** Picks a front/back cheque image right in the capture form — uploaded once the cheque is created below. */
|
||||
onCaptureFileSelected(side: 'front' | 'back', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
if (side === 'front') {
|
||||
if (this.frontPreviewUrl) {
|
||||
URL.revokeObjectURL(this.frontPreviewUrl);
|
||||
}
|
||||
this.selectedFrontFile = file;
|
||||
this.frontPreviewUrl = previewUrl;
|
||||
} else {
|
||||
if (this.backPreviewUrl) {
|
||||
URL.revokeObjectURL(this.backPreviewUrl);
|
||||
}
|
||||
this.selectedBackFile = file;
|
||||
this.backPreviewUrl = previewUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private resetCaptureFiles(): void {
|
||||
if (this.frontPreviewUrl) {
|
||||
URL.revokeObjectURL(this.frontPreviewUrl);
|
||||
}
|
||||
if (this.backPreviewUrl) {
|
||||
URL.revokeObjectURL(this.backPreviewUrl);
|
||||
}
|
||||
this.selectedFrontFile = null;
|
||||
this.selectedBackFile = null;
|
||||
this.frontPreviewUrl = null;
|
||||
this.backPreviewUrl = null;
|
||||
}
|
||||
|
||||
/** Front + back cheque image are mandatory before a cheque can be captured — checked in the template too. */
|
||||
canSubmitCapture(): boolean {
|
||||
return this.captureForm.valid && !!this.selectedFrontFile && !!this.selectedBackFile && !this.busy;
|
||||
}
|
||||
|
||||
submitCapture(): void {
|
||||
if (!this.canSubmitCapture()) {
|
||||
if (this.captureForm.valid && (!this.selectedFrontFile || !this.selectedBackFile)) {
|
||||
this.notify('Front and back cheque image are both required', true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.captureForm.value;
|
||||
const payload = {
|
||||
depositingAccountType: raw.depositingAccountType,
|
||||
depositingAccountId: raw.depositingAccountId,
|
||||
chequeNumber: raw.chequeNumber,
|
||||
amount: raw.amount,
|
||||
payeeName: raw.payeeName || undefined,
|
||||
drawerBankName: raw.drawerBankName || undefined,
|
||||
drawerBankMicr: raw.drawerBankMicr || undefined,
|
||||
drawerIfsc: raw.drawerIfsc || undefined,
|
||||
depositedDate: this.toApiDate(raw.depositedDate),
|
||||
stopPaymentFlag: !!raw.stopPaymentFlag,
|
||||
iqaStatus: raw.iqaStatus || undefined,
|
||||
iqaDetail: raw.iqaDetail || undefined,
|
||||
drawerAccountNumber: raw.drawerAccountNumber || undefined,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en'
|
||||
};
|
||||
this.ctsService.createOutwardPresentment(payload).subscribe({
|
||||
next: (res: any) => {
|
||||
const newId = res?.resourceId;
|
||||
const front = this.selectedFrontFile;
|
||||
const back = this.selectedBackFile;
|
||||
if (!newId || (!front && !back)) {
|
||||
this.finishCapture('Cheque captured');
|
||||
return;
|
||||
}
|
||||
// upload whichever image(s) were picked, in the same Submit action, before closing the form.
|
||||
// Sequential (concat), NOT parallel (forkJoin): two concurrent updates to the same row would race
|
||||
// against its @Version optimistic lock and throw OptimisticLockException on the second write.
|
||||
const uploads: Observable<any>[] = [];
|
||||
if (front) {
|
||||
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'front', front));
|
||||
}
|
||||
if (back) {
|
||||
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'back', back));
|
||||
}
|
||||
concat(...uploads).subscribe({
|
||||
complete: () => this.finishCapture('Cheque captured with image(s)'),
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
clearingError(err, 'Cheque captured, but image upload failed — upload it from the table row'),
|
||||
true
|
||||
);
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
|
||||
this.loadPresentments();
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Could not capture the cheque'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelCapture(): void {
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
}
|
||||
|
||||
private finishCapture(message: string): void {
|
||||
this.busy = false;
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
|
||||
this.notify(message);
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
/** Fills the capture form with realistic sample data in one click, for quick demo/testing. */
|
||||
fillDummyData(): void {
|
||||
const stamp = Date.now().toString().slice(-6);
|
||||
const banks = [
|
||||
{ name: 'IDFC First Bank', micr: '400229015', ifsc: 'IDFC0000045' },
|
||||
{ name: 'HDFC Bank', micr: '400240012', ifsc: 'HDFC0000078' },
|
||||
{ name: 'ICICI Bank', micr: '400229033', ifsc: 'ICIC0000112' }
|
||||
];
|
||||
const bank = banks[Math.floor(Math.random() * banks.length)];
|
||||
const today = new Date();
|
||||
|
||||
this.captureForm.patchValue({
|
||||
depositingAccountType: 'SAVINGS',
|
||||
depositingAccountId: 1,
|
||||
chequeNumber: stamp,
|
||||
amount: (Math.floor(Math.random() * 900) + 100) * 100,
|
||||
payeeName: 'Test Payee ' + stamp,
|
||||
drawerBankName: bank.name,
|
||||
drawerBankMicr: bank.micr,
|
||||
drawerIfsc: bank.ifsc,
|
||||
drawerAccountNumber: 'XXXXXXXX' + stamp,
|
||||
depositedDate: today,
|
||||
stopPaymentFlag: false,
|
||||
iqaStatus: 'PASS',
|
||||
iqaDetail: 'Auto-filled sample data'
|
||||
});
|
||||
this.notify('Dummy values filled — review and Submit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual stand-in for the clearing-session scheduler (currently disabled): bundles every CAPTURED cheque
|
||||
* into one CTS batch file and sends it to the clearing house in a single go.
|
||||
*/
|
||||
sendPendingBatch(): void {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.ctsService.sendPendingOutwardBatch().subscribe({
|
||||
next: (res: any) => {
|
||||
this.busy = false;
|
||||
const count = res?.resourceId ?? '';
|
||||
this.notify(count ? `Batch sent to the clearing house — ${count} cheque(s)` : 'No pending cheques to send');
|
||||
this.loadPresentments();
|
||||
this.loadBatches();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Could not send the pending batch'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>Positive Pay (RBI PPS)</h2>
|
||||
<p class="muted">
|
||||
Pre-register high-value cheques (≥ ₹50,000). Inward clearing returns an unregistered or mismatched high-value
|
||||
cheque with reason code 13.
|
||||
</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="showForm = !showForm">
|
||||
<fa-icon icon="plus"></fa-icon> Register Cheque
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showForm) {
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select formControlName="accountType">
|
||||
<mat-option value="SAVINGS">Savings</mat-option>
|
||||
<mat-option value="CURRENT">Current</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Account Id</mat-label>
|
||||
<input matInput type="number" formControlName="accountId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Cheque Number</mat-label>
|
||||
<input matInput formControlName="chequeNumber" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Instrument Date</mat-label>
|
||||
<input matInput [matDatepicker]="idp" formControlName="instrumentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="idp"></mat-datepicker-toggle>
|
||||
<mat-datepicker #idp></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput type="number" formControlName="amount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Payee Name</mat-label>
|
||||
<input matInput formControlName="payeeName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Channel</mat-label>
|
||||
<mat-select formControlName="channel">
|
||||
<mat-option value="BRANCH">Branch</mat-option>
|
||||
<mat-option value="NETBANKING">Net Banking</mat-option>
|
||||
<mat-option value="MOBILE">Mobile</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button mat-button type="button" (click)="showForm = false">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
@for (s of ['ACTIVE', 'CANCELLED', 'CONSUMED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z1 table-wrap">
|
||||
<table mat-table [dataSource]="registrations">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>#</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>Account</th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
{{ r.accountNo || r.accountId }} <span class="muted">({{ r.accountType }})</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="chequeNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Cheque #</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.chequeNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="instrumentDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Instrument Date</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.instrumentDate | date: 'mediumDate' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.amount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="payeeName">
|
||||
<th mat-header-cell *matHeaderCellDef>Payee</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.payeeName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
@if (r.status === 'ACTIVE') {
|
||||
<button mat-icon-button color="warn" (click)="cancel(r)" matTooltip="Cancel">
|
||||
<fa-icon icon="ban"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.Reconciliation' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="primary" (click)="showRunForm = !showRunForm">
|
||||
<fa-icon icon="plus"></fa-icon> {{ 'labels.buttons.Run Reconciliation' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showRunForm) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="runForm" (ngSubmit)="submitRun()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reconciliation Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="reconPicker" formControlName="reconciliationDate" required />
|
||||
<mat-datepicker-toggle matSuffix [for]="reconPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #reconPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Source Reference' | translate }}</mat-label>
|
||||
<input matInput formControlName="sourceReference" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.CBS Total' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="cbsTotal" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Clearing House Total' | translate }}</mat-label>
|
||||
<input matInput type="number" formControlName="clearingHouseTotal" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Detail' | translate }}</mat-label>
|
||||
<input matInput formControlName="detail" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="showRunForm = false">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="runForm.invalid || busy">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="reconciliations">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="reconciliationDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Reconciliation Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.reconciliationDate ? (r.reconciliationDate | dateFormat) : '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="sourceReference">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.sourceReference || '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="cbsTotal">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.CBS Total' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r" class="num">{{ r.cbsTotal }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="clearingHouseTotal">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Clearing House Total' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r" class="num">{{ r.clearingHouseTotal }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="difference">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Difference' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r" class="num">{{ r.difference ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (reconciliations.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>{{ 'labels.menus.Settlement' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
|
||||
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
@for (s of ['CREATED', 'POSTED', 'REJECTED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper mat-elevation-z1">
|
||||
<table mat-table [dataSource]="batches">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="settlementDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Settlement Date' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.settlementDate ? (b.settlementDate | dateFormat) : '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="sourceReference">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.sourceReference }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="passed">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Passed' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">
|
||||
{{ b.passedCount ?? '–' }}
|
||||
@if (b.totalPassedAmount != null) {
|
||||
<span class="muted">{{ b.totalPassedAmount }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="returned">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Returned' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">
|
||||
{{ b.returnedCount ?? '–' }}
|
||||
@if (b.totalReturnedAmount != null) {
|
||||
<span class="muted">{{ b.totalReturnedAmount }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="netAmount">
|
||||
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Net Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b" class="num">{{ b.netAmount ?? '–' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
|
||||
@if (b.glTransactionId) {
|
||||
<span class="muted">GL #{{ b.glTransactionId }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
@if (b.status === 'CREATED') {
|
||||
<button mat-button color="primary" (click)="post(b)">{{ 'labels.buttons.Post' | translate }}</button>
|
||||
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
@if (batches.length === 0) {
|
||||
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
|
||||
}
|
||||
@if (totalRecords > 0) {
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (rejectForId) {
|
||||
<mat-card class="add-form">
|
||||
<mat-card-content>
|
||||
<h3>{{ 'labels.inputs.Reject Settlement' | translate }}</h3>
|
||||
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForId)">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
|
||||
<input matInput formControlName="reason" required />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button type="button" (click)="rejectForId = null">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
|
||||
{{ 'labels.buttons.Reject' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="page">
|
||||
@if (batch) {
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>Batch {{ batch.batchReference }}</h2>
|
||||
<p class="muted">
|
||||
{{ batch.batchType }} · value {{ batch.valueDate | date: 'mediumDate' }} · {{ batch.totalTxnCount }} txns ·
|
||||
{{ batch.totalAmount }}
|
||||
<span class="pill" [ngClass]="pillClass(batch.status)">{{ batch.status }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@if (batch.status === 'CREATED') {
|
||||
<button mat-raised-button color="primary" (click)="process()" [disabled]="busy">
|
||||
<fa-icon icon="check-circle"></fa-icon> Process Batch
|
||||
</button>
|
||||
}
|
||||
@if (batch.status === 'PROCESSED') {
|
||||
<button mat-stroked-button color="primary" (click)="prepareSettlement()">
|
||||
<fa-icon icon="university"></fa-icon> Prepare Settlement
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z1 table-wrap">
|
||||
<table mat-table [dataSource]="transactions">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>#</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>Account</th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
{{ t.accountNo || t.accountId }} <span class="muted">({{ t.accountType }})</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="txnType">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.txnType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let t">{{ t.amount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="checks">
|
||||
<th mat-header-cell *matHeaderCellDef>Validation</th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
<span
|
||||
*ngFor="let c of checks(t)"
|
||||
class="check"
|
||||
[class.ok]="c.ok === true"
|
||||
[class.bad]="c.ok === false"
|
||||
[class.na]="c.ok === null || c.ok === undefined"
|
||||
[matTooltip]="c.label"
|
||||
>{{ c.label[0] }}</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="decision">
|
||||
<th mat-header-cell *matHeaderCellDef>Decision</th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
<span class="pill" [ngClass]="pillClass(t.decision)">{{ t.decision }}</span>
|
||||
@if (t.returnReasonCode) {
|
||||
<span class="muted"> {{ t.returnReasonCode }}</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
<span class="pill" [ngClass]="pillClass(t.status)">{{ t.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let t">
|
||||
@if (t.decision === 'PENDING') {
|
||||
<button mat-stroked-button color="primary" (click)="decide(t)">Decide</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+41
@@ -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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Decide Transaction #{{ data.transaction.id }}</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="muted">
|
||||
{{ data.transaction.txnType }} · {{ data.transaction.accountType }} account
|
||||
{{ data.transaction.accountNo || data.transaction.accountId }} · amount {{ data.transaction.amount }}
|
||||
</p>
|
||||
|
||||
<div class="toggle">
|
||||
<button mat-stroked-button [class.sel-ok]="decision === 'PASS'" (click)="setDecision('PASS')">Pass</button>
|
||||
<button mat-stroked-button [class.sel-bad]="decision === 'RETURN'" (click)="setDecision('RETURN')">Return</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form">
|
||||
@if (decision === 'RETURN') {
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Return Reason Code</mat-label>
|
||||
<mat-select formControlName="returnReasonCode">
|
||||
@for (rc of reasonCodes; track rc.code) {
|
||||
<mat-option [value]="rc.code">{{ rc.code }} — {{ rc.description }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput rows="2" formControlName="reason"></textarea>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Cancel</button>
|
||||
<button mat-raised-button color="primary" (click)="submit()" [disabled]="busy">Confirm</button>
|
||||
</mat-dialog-actions>
|
||||
+33
@@ -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%;
|
||||
}
|
||||
+108
@@ -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<TxnDecisionDialogComponent>>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h2>NACH Batches</h2>
|
||||
<button mat-raised-button color="primary" (click)="showForm = !showForm">
|
||||
<fa-icon icon="plus"></fa-icon> New Batch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showForm) {
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Batch Type</mat-label>
|
||||
<mat-select formControlName="batchType">
|
||||
<mat-option value="DEBIT">Debit</mat-option>
|
||||
<mat-option value="CREDIT">Credit</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Value Date</mat-label>
|
||||
<input matInput [matDatepicker]="vd" formControlName="valueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="vd"></mat-datepicker-toggle>
|
||||
<mat-datepicker #vd></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sponsor Bank Id</mat-label>
|
||||
<input matInput type="number" formControlName="sponsorBankId" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<h4>Transactions</h4>
|
||||
<div formArrayName="transactions">
|
||||
@for (t of transactions.controls; track t; let i = $index) {
|
||||
<div class="txn-row" [formGroupName]="i">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mandate Id</mat-label>
|
||||
<input matInput type="number" formControlName="mandateId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Acct Type</mat-label>
|
||||
<mat-select formControlName="accountType">
|
||||
<mat-option value="SAVINGS">Savings</mat-option>
|
||||
<mat-option value="CURRENT">Current</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Account Id</mat-label>
|
||||
<input matInput type="number" formControlName="accountId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput type="number" formControlName="amount" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
type="button"
|
||||
(click)="removeTxn(i)"
|
||||
[disabled]="transactions.length === 1"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button mat-stroked-button type="button" (click)="addTxn()">
|
||||
<fa-icon icon="plus"></fa-icon> Add transaction
|
||||
</button>
|
||||
|
||||
<div class="actions">
|
||||
<button mat-button type="button" (click)="showForm = false">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
@for (s of ['CREATED', 'PROCESSED', 'SETTLED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z1 table-wrap">
|
||||
<table mat-table [dataSource]="batches">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>#</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="batchReference">
|
||||
<th mat-header-cell *matHeaderCellDef>Reference</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="batchType">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.batchType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="valueDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Value Date</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.valueDate | date: 'mediumDate' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="txns">
|
||||
<th mat-header-cell *matHeaderCellDef>Txns</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.totalTxnCount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let b">{{ b.totalAmount }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let b">
|
||||
<button mat-icon-button color="primary" (click)="open(b)" matTooltip="Open">
|
||||
<fa-icon icon="arrow-right"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns" (click)="open(row)" class="clickable"></tr>
|
||||
</table>
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h2>NACH Mandates</h2>
|
||||
<button mat-raised-button color="primary" (click)="showForm = !showForm">
|
||||
<fa-icon icon="plus"></fa-icon> Register Mandate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showForm) {
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select formControlName="accountType">
|
||||
<mat-option value="SAVINGS">Savings</mat-option>
|
||||
<mat-option value="CURRENT">Current</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Account Id</mat-label>
|
||||
<input matInput type="number" formControlName="accountId" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mandate Type</mat-label>
|
||||
<mat-select formControlName="mandateType">
|
||||
<mat-option value="DEBIT">Debit (EMI / SIP)</mat-option>
|
||||
<mat-option value="CREDIT">Credit (Salary / Subsidy)</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput type="number" formControlName="amount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Amount</mat-label>
|
||||
<input matInput type="number" formControlName="maxAmount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Frequency</mat-label>
|
||||
<mat-select formControlName="frequency">
|
||||
<mat-option value="MONTHLY">Monthly</mat-option>
|
||||
<mat-option value="QUARTERLY">Quarterly</mat-option>
|
||||
<mat-option value="YEARLY">Yearly</mat-option>
|
||||
<mat-option value="ADHOC">As presented</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Start Date</mat-label>
|
||||
<input matInput [matDatepicker]="sd" formControlName="startDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="sd"></mat-datepicker-toggle>
|
||||
<mat-datepicker #sd></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>End Date</mat-label>
|
||||
<input matInput [matDatepicker]="ed" formControlName="endDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="ed"></mat-datepicker-toggle>
|
||||
<mat-datepicker #ed></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sponsor Bank Id</mat-label>
|
||||
<input matInput type="number" formControlName="sponsorBankId" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Debtor / Beneficiary Name</mat-label>
|
||||
<input matInput formControlName="debtorName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Purpose</mat-label>
|
||||
<input matInput formControlName="purpose" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button mat-button type="button" (click)="showForm = false">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Register</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
@for (s of ['ACTIVE', 'CANCELLED', 'EXPIRED']; track s) {
|
||||
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mat-elevation-z1 table-wrap">
|
||||
<table mat-table [dataSource]="mandates">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>#</th>
|
||||
<td mat-cell *matCellDef="let m">{{ m.id }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="mandateReference">
|
||||
<th mat-header-cell *matHeaderCellDef>Reference</th>
|
||||
<td mat-cell *matCellDef="let m">{{ m.mandateReference }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>Account</th>
|
||||
<td mat-cell *matCellDef="let m">
|
||||
{{ m.accountNo || m.accountId }} <span class="muted">({{ m.accountType }})</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="mandateType">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let m">{{ m.mandateType }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let m">
|
||||
{{ m.amount }} <span class="muted" *ngIf="m.maxAmount">/ max {{ m.maxAmount }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="validity">
|
||||
<th mat-header-cell *matHeaderCellDef>Validity</th>
|
||||
<td mat-cell *matCellDef="let m">
|
||||
{{ m.startDate | date: 'mediumDate' }} – {{ m.endDate ? (m.endDate | date: 'mediumDate') : '…' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let m">
|
||||
<span class="pill" [ngClass]="pillClass(m.status)">{{ m.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let m">
|
||||
@if (m.status === 'ACTIVE') {
|
||||
<button mat-icon-button color="warn" (click)="cancel(m)" matTooltip="Cancel mandate">
|
||||
<fa-icon icon="ban"></fa-icon>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="columns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: columns"></tr>
|
||||
</table>
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="dashboard">
|
||||
<h2>NACH Clearing</h2>
|
||||
<p class="subtitle">National Automated Clearing House — recurring, mandate-based bulk debits and credits.</p>
|
||||
|
||||
<div class="card-grid">
|
||||
@for (card of cards; track card.title) {
|
||||
<mat-card class="nav-card" [routerLink]="card.link">
|
||||
<div class="icon"><fa-icon [icon]="card.icon"></fa-icon></div>
|
||||
<div class="body">
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.description }}</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
return this.http.get(`/nachclearing/mandates/${id}`);
|
||||
}
|
||||
|
||||
createMandate(payload: any): Observable<any> {
|
||||
return this.http.post('/nachclearing/mandates', payload);
|
||||
}
|
||||
|
||||
cancelMandate(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/nachclearing/mandates/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'cancel')
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Batches -----
|
||||
|
||||
getBatches(status?: string, offset = 0, limit = 20): Observable<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
return this.http.get(`/nachclearing/batches/${id}/transactions`);
|
||||
}
|
||||
|
||||
createBatch(payload: any): Observable<any> {
|
||||
return this.http.post('/nachclearing/batches', payload);
|
||||
}
|
||||
|
||||
processBatch(id: string | number): Observable<any> {
|
||||
return this.http.post(`/nachclearing/batches/${id}`, {}, { params: new HttpParams().set('command', 'process') });
|
||||
}
|
||||
|
||||
// ----- Transactions -----
|
||||
|
||||
getTransaction(id: string | number): Observable<any> {
|
||||
return this.http.get(`/nachclearing/transactions/${id}`);
|
||||
}
|
||||
|
||||
passTransaction(id: string | number): Observable<any> {
|
||||
return this.http.post(`/nachclearing/transactions/${id}`, {}, { params: new HttpParams().set('command', 'pass') });
|
||||
}
|
||||
|
||||
returnTransaction(id: string | number, payload: any): Observable<any> {
|
||||
return this.http.post(`/nachclearing/transactions/${id}`, payload, {
|
||||
params: new HttpParams().set('command', 'return')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260707',
|
||||
'hash': '8a1b7d5d'
|
||||
'version': '260720',
|
||||
'hash': 'ed2a40a8'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user