Add customer search dialog and sponsor DD/reconciliation modules

Adds sponsor-dd and sponsor-dd-reconciliation feature modules, a
shared customer search dialog, and wires them into routing/shell/
organization screens alongside cashier and deposit-request updates.
This commit is contained in:
2026-07-22 11:59:31 +05:30
parent 1692925d0d
commit 3cf01e2733
36 changed files with 6257 additions and 18 deletions
@@ -0,0 +1,146 @@
<!--
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="detail-container">
<div class="header-row">
<button
mat-button
routerLink="/sponsor-dd-reconciliation"
style="margin-bottom: 8px; color: #64748b; padding-left: 0"
>
&larr; Back to Batches
</button>
<h2 *ngIf="batchDetails">Reconciliation Batch: {{ batchDetails.batchReference }}</h2>
<p *ngIf="batchDetails">
File: <strong>{{ batchDetails.fileName }}</strong> | Status: <strong>{{ batchDetails.status }}</strong>
</p>
</div>
<div class="batch-summary" *ngIf="batchDetails">
<div class="summary-card">
<span class="summary-label">Total Scroll Items</span>
<span class="summary-value">{{ batchDetails.totalRecords }}</span>
</div>
<div class="summary-card">
<span class="summary-label">Total Amount</span>
<span class="summary-value">₹{{ batchDetails.totalAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-card" style="border-left: 4px solid #16a34a">
<span class="summary-label" style="color: #16a34a">Auto Matched</span>
<span class="summary-value" style="color: #16a34a">{{ batchDetails.successCount }}</span>
</div>
<div
class="summary-card"
[style.border-left]="batchDetails.mismatchCount > 0 ? '4px solid #dc2626' : '4px solid #e2e8f0'"
>
<span class="summary-label" [style.color]="batchDetails.mismatchCount > 0 ? '#dc2626' : '#64748b'"
>Exceptions</span
>
<span class="summary-value" [style.color]="batchDetails.mismatchCount > 0 ? '#dc2626' : '#334155'">{{
batchDetails.mismatchCount
}}</span>
</div>
</div>
<div class="filter-chips">
<span style="font-size: 13px; font-weight: 500; color: #64748b">Filter Status:</span>
<mat-chip-listbox>
<mat-chip-option [selected]="selectedFilter === 'ALL'" (click)="applyFilter('ALL')">All</mat-chip-option>
<mat-chip-option [selected]="selectedFilter === 'EXACT_MATCH'" (click)="applyFilter('EXACT_MATCH')"
>Exact Match</mat-chip-option
>
<mat-chip-option [selected]="selectedFilter === 'AMOUNT_MISMATCH'" (click)="applyFilter('AMOUNT_MISMATCH')"
>Amount Mismatch</mat-chip-option
>
<mat-chip-option [selected]="selectedFilter === 'UNADVISED_CLEARING'" (click)="applyFilter('UNADVISED_CLEARING')"
>Unadvised Clearing</mat-chip-option
>
<mat-chip-option [selected]="selectedFilter === 'DUPLICATE_CLEARING'" (click)="applyFilter('DUPLICATE_CLEARING')"
>Duplicate Clearing</mat-chip-option
>
</mat-chip-listbox>
</div>
<mat-card>
<table mat-table [dataSource]="filteredRecords">
<!-- DD Number Column -->
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD Number</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<!-- Clearing Amount Column -->
<ng-container matColumnDef="clearingAmount">
<th mat-header-cell *matHeaderCellDef>Scroll Amount</th>
<td mat-cell *matCellDef="let element">₹{{ element.clearingAmount | number: '1.2-2' }}</td>
</ng-container>
<!-- Clearing Date Column -->
<ng-container matColumnDef="clearingDate">
<th mat-header-cell *matHeaderCellDef>Clearing Date</th>
<td mat-cell *matCellDef="let element">{{ element.clearingDate }}</td>
</ng-container>
<!-- Ref Number Column -->
<ng-container matColumnDef="clearingRefNumber">
<th mat-header-cell *matHeaderCellDef>Clearing Ref No.</th>
<td mat-cell *matCellDef="let element">{{ element.clearingRefNumber }}</td>
</ng-container>
<!-- Match Status Column -->
<ng-container matColumnDef="matchStatus">
<th mat-header-cell *matHeaderCellDef>Match Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.matchStatus.toLowerCase()">
{{ element.matchStatus.replace('_', ' ') }}
</span>
</td>
</ng-container>
<!-- Remarks Column -->
<ng-container matColumnDef="remarks">
<th mat-header-cell *matHeaderCellDef>Remarks / Resolution</th>
<td mat-cell *matCellDef="let element">{{ element.remarks || '-' }}</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<button
mat-flat-button
color="accent"
size="small"
*ngIf="element.matchStatus !== 'EXACT_MATCH' && !element.remarks?.includes('Resolved')"
(click)="openResolveDialog(element)"
>
Resolve Match
</button>
<span
*ngIf="element.matchStatus === 'EXACT_MATCH' || element.remarks?.includes('Resolved')"
style="color: #16a34a; font-size: 12px; font-weight: 500"
>
<fa-icon icon="check"></fa-icon> Reconciled
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="filteredRecords.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No scroll records found for the selected status filter.
</div>
</mat-card>
</div>
@@ -0,0 +1,342 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatChipsModule } from '@angular/material/chips';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
@Component({
selector: 'mifosx-reco-batch-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatSnackBarModule,
FaIconComponent
],
templateUrl: './reco-batch-detail.component.html',
styles: [
`
.detail-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.header-row {
margin-bottom: 24px;
}
.header-row h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.batch-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.summary-card {
background-color: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
}
.summary-label {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
text-transform: uppercase;
font-weight: 500;
}
.summary-value {
font-size: 20px;
font-weight: 600;
color: #334155;
}
.filter-chips {
margin-bottom: 16px;
display: flex;
gap: 8px;
align-items: center;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
}
.status-badge.exact_match {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.amount_mismatch {
background-color: #fee2e2;
color: #b91c1c;
}
.status-badge.unadvised_clearing {
background-color: #fef3c7;
color: #b45309;
}
.status-badge.duplicate_clearing {
background-color: #f1f5f9;
color: #475569;
}
.action-btn {
color: #7c3aed;
font-weight: 500;
}
`
]
})
export class RecoBatchDetailComponent implements OnInit {
private recoService = inject(SponsorDDReconciliationService);
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
batchId!: number;
batchDetails: any = null;
records: any[] = [];
filteredRecords: any[] = [];
selectedFilter: string = 'ALL';
displayedColumns: string[] = [
'ddNumber',
'clearingAmount',
'clearingDate',
'clearingRefNumber',
'matchStatus',
'remarks',
'actions'
];
ngOnInit(): void {
this.batchId = +this.route.snapshot.params['batchId'];
this.loadBatchDetails();
this.loadRecords();
}
loadBatchDetails(): void {
this.recoService.getReconciliationBatch(this.batchId).subscribe({
next: (data) => {
this.batchDetails = data;
}
});
}
loadRecords(): void {
this.recoService.getReconciliationRecords(this.batchId).subscribe({
next: (data) => {
this.records = data;
this.applyFilter(this.selectedFilter);
},
error: (err) => {
this.snackBar.open('Error loading batch records: ' + (err.error?.message || err.message), 'Close', {
duration: 5000
});
}
});
}
applyFilter(filter: string): void {
this.selectedFilter = filter;
if (filter === 'ALL') {
this.filteredRecords = this.records;
} else {
this.filteredRecords = this.records.filter((r) => r.matchStatus === filter);
}
}
openResolveDialog(record: any): void {
const dialogRef = this.dialog.open(ResolveMismatchDialogComponent, {
width: '500px',
data: { record },
disableClose: true
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.loadBatchDetails();
this.loadRecords();
}
});
}
}
// ── RESOLVE MISMATCH DIALOG COMPONENT ──
@Component({
selector: 'mifosx-resolve-mismatch-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Resolve Clearing Exception (DD No: {{ data.record.ddNumber }})</h2>
<mat-dialog-content>
<form [formGroup]="resolveForm" class="dialog-form">
<p>
Clearing Item status is <strong>{{ data.record.matchStatus.replace('_', ' ') }}</strong
>. Sponsor Scroll Amount: <strong>₹{{ data.record.clearingAmount | number: '1.2-2' }}</strong
>.
</p>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Resolution Action</mat-label>
<mat-select formControlName="actionType">
<mat-option value="FORCE_MATCH">Force Match (Map to Customer Debit)</mat-option>
</mat-select>
<mat-error *ngIf="resolveForm.get('actionType')?.hasError('required')">Action is required</mat-error>
</mat-form-field>
<div class="row">
<mat-form-field appearance="outline" class="half-width">
<mat-label>Savings Transaction ID</mat-label>
<input matInput type="number" formControlName="resolvedSavingsTxnId" />
</mat-form-field>
<mat-form-field appearance="outline" class="half-width">
<mat-label>Current Transaction ID</mat-label>
<input matInput type="number" formControlName="resolvedCurrentTxnId" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Supervisor Authorization Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
<mat-error *ngIf="resolveForm.get('remarks')?.hasError('required')">Remarks are required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="resolveForm.invalid || loading" (click)="onSubmit()">
Resolve Mismatch
</button>
</mat-dialog-actions>
`,
styles: [
`
.dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
}
.full-width {
width: 100%;
}
.row {
display: flex;
gap: 16px;
}
.half-width {
flex: 1;
}
`
]
})
export class ResolveMismatchDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private recoService = inject(SponsorDDReconciliationService);
private dialogRef = inject(MatDialogRef<ResolveMismatchDialogComponent>);
private snackBar = inject(MatSnackBar);
protected data = inject(MAT_DIALOG_DATA);
resolveForm!: FormGroup;
loading = false;
ngOnInit(): void {
this.resolveForm = this.fb.group({
actionType: [
'FORCE_MATCH',
Validators.required
],
resolvedSavingsTxnId: [null],
resolvedCurrentTxnId: [null],
remarks: [
'',
Validators.required
]
});
}
onCancel(): void {
this.dialogRef.close(false);
}
onSubmit(): void {
if (this.resolveForm.invalid) return;
this.loading = true;
const formVal = this.resolveForm.value;
const payload = {
actionType: formVal.actionType,
resolvedSavingsTxnId: formVal.resolvedSavingsTxnId ? formVal.resolvedSavingsTxnId : undefined,
resolvedCurrentTxnId: formVal.resolvedCurrentTxnId ? formVal.resolvedCurrentTxnId : undefined
};
this.recoService.resolveMismatch(this.data.record.id, payload).subscribe({
next: () => {
this.snackBar.open('Mismatch resolved successfully', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
}
});
}
}
@@ -0,0 +1,109 @@
<!--
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="reco-container">
<div class="header-row">
<div class="title-area">
<h2>Demand Draft Reconciliation</h2>
<p>
Upload and process daily clearing scroll statements from the Sponsor Bank to auto-match and reconcile issued
drafts.
</p>
</div>
<button mat-flat-button color="primary" [routerLink]="['/sponsor-dd-reconciliation/upload']">
Upload Clearing Scroll
</button>
</div>
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- Batch Reference Column -->
<ng-container matColumnDef="batchReference">
<th mat-header-cell *matHeaderCellDef>Batch Reference</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.batchReference }}</strong>
</td>
</ng-container>
<!-- File Name Column -->
<ng-container matColumnDef="fileName">
<th mat-header-cell *matHeaderCellDef>Clearing File</th>
<td mat-cell *matCellDef="let element">{{ element.fileName }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status.replace('_', ' ') }}
</span>
</td>
</ng-container>
<!-- Total Records Column -->
<ng-container matColumnDef="totalRecords">
<th mat-header-cell *matHeaderCellDef>Total Items</th>
<td mat-cell *matCellDef="let element">{{ element.totalRecords }}</td>
</ng-container>
<!-- Total Amount Column -->
<ng-container matColumnDef="totalAmount">
<th mat-header-cell *matHeaderCellDef>Total Amount</th>
<td mat-cell *matCellDef="let element">₹{{ element.totalAmount | number: '1.2-2' }}</td>
</ng-container>
<!-- Success Count Column -->
<ng-container matColumnDef="successCount">
<th mat-header-cell *matHeaderCellDef>Matched</th>
<td mat-cell *matCellDef="let element" style="color: #15803d; font-weight: 500">
{{ element.successCount }}
</td>
</ng-container>
<!-- Mismatch Count Column -->
<ng-container matColumnDef="mismatchCount">
<th mat-header-cell *matHeaderCellDef>Exceptions</th>
<td
mat-cell
*matCellDef="let element"
[style.color]="element.mismatchCount > 0 ? '#b91c1c' : '#475569'"
[style.font-weight]="element.mismatchCount > 0 ? '600' : '400'"
>
{{ element.mismatchCount }}
</td>
</ng-container>
<!-- Created Date Column -->
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef>Uploaded On</th>
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'medium' }}</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd-reconciliation/batches', element.id]">
View Details
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No clearing files uploaded yet. Click "Upload Clearing Scroll" to reconcile clearing sheets.
</div>
</mat-card>
</div>
@@ -0,0 +1,137 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
@Component({
selector: 'mifosx-reco-batch-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatButtonModule,
MatSnackBarModule,
FaIconComponent
],
templateUrl: './reco-batch-list.component.html',
styles: [
`
.reco-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title-area h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.title-area p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.reconciled {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.partially_reconciled {
background-color: #fef3c7;
color: #b45309;
}
.status-badge.processing {
background-color: #dbeafe;
color: #1d4ed8;
}
.status-badge.failed {
background-color: #fee2e2;
color: #b91c1c;
}
.action-btn {
color: #7c3aed;
font-weight: 500;
}
`
]
})
export class RecoBatchListComponent implements OnInit {
private recoService = inject(SponsorDDReconciliationService);
private snackBar = inject(MatSnackBar);
displayedColumns: string[] = [
'batchReference',
'fileName',
'status',
'totalRecords',
'totalAmount',
'successCount',
'mismatchCount',
'createdDate',
'actions'
];
dataSource: any[] = [];
ngOnInit(): void {
this.loadBatches();
}
loadBatches(): void {
this.recoService.getReconciliationBatches().subscribe({
next: (data) => {
this.dataSource = data;
},
error: (err) => {
this.snackBar.open('Error loading reconciliation batches: ' + (err.error?.message || err.message), 'Close', {
duration: 5000
});
}
});
}
}
@@ -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/.
-->
<div class="upload-container">
<div class="header-row">
<button
mat-button
routerLink="/sponsor-dd-reconciliation"
style="margin-bottom: 8px; color: #64748b; padding-left: 0"
>
&larr; Back to Batches
</button>
<h2>Upload Sponsor Clearing Scroll</h2>
<p>Upload a CSV format clearing scroll file containing Sponsor Bank draft payments.</p>
</div>
<mat-card>
<div class="dropzone" (click)="triggerFileSelect(fileInput)">
<input type="file" #fileInput (change)="onFileSelected($event)" accept=".csv" style="display: none" />
<div class="dropzone-icon">
<fa-icon icon="upload"></fa-icon>
</div>
<h3>Choose a clearing file or drag &amp; drop it here</h3>
<p style="color: #64748b; font-size: 13px">Supports only .csv format files.</p>
</div>
<div class="file-details" *ngIf="selectedFile">
<div>
<div class="file-name">{{ selectedFile.name }}</div>
<div class="file-size">{{ selectedFile.size / 1024 | number: '1.1-1' }} KB</div>
</div>
<button mat-icon-button color="warn" (click)="selectedFile = null" [disabled]="loading">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="progress-section" *ngIf="loading">
<mat-progress-bar mode="indeterminate" color="primary"></mat-progress-bar>
<div class="progress-text">{{ progressMessage }}</div>
</div>
<div class="actions">
<button mat-button routerLink="/sponsor-dd-reconciliation" [disabled]="loading">Cancel</button>
<button mat-flat-button color="primary" [disabled]="!selectedFile || loading" (click)="onSubmit()">
Process Reconciliation
</button>
</div>
</mat-card>
</div>
@@ -0,0 +1,176 @@
/**
* 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/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SponsorDDReconciliationService } from '../sponsor-dd-reconciliation.service';
@Component({
selector: 'mifosx-reco-batch-upload',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatCardModule,
MatButtonModule,
MatSnackBarModule,
MatProgressBarModule,
FaIconComponent
],
templateUrl: './reco-batch-upload.component.html',
styles: [
`
.upload-container {
padding: 24px;
max-width: 600px;
margin: 0 auto;
}
.header-row {
margin-bottom: 24px;
}
.header-row h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.header-row p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
padding: 32px;
}
.dropzone {
border: 2px dashed #cbd5e1;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
background-color: #f8fafc;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
}
.dropzone:hover {
border-color: #7c3aed;
background-color: #f1f5f9;
}
.dropzone-icon {
font-size: 48px;
color: #94a3b8;
margin-bottom: 16px;
}
.file-details {
margin-top: 24px;
padding: 16px;
background-color: #f1f5f9;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-name {
font-weight: 500;
color: #334155;
}
.file-size {
font-size: 12px;
color: #64748b;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.progress-section {
margin-top: 24px;
text-align: center;
}
.progress-text {
font-size: 12px;
color: #64748b;
margin-top: 8px;
}
`
]
})
export class RecoBatchUploadComponent {
private recoService = inject(SponsorDDReconciliationService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
selectedFile: File | null = null;
loading = false;
progressMessage = '';
onFileSelected(event: any): void {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
}
}
triggerFileSelect(fileInput: HTMLInputElement): void {
fileInput.click();
}
onSubmit(): void {
if (!this.selectedFile) return;
this.loading = true;
this.progressMessage = 'Reading file and preparing upload payload...';
const reader = new FileReader();
reader.onload = () => {
const fileContent = reader.result as string;
this.progressMessage = 'Uploading clearing scroll for processing...';
this.recoService
.uploadClearingScroll({
fileName: this.selectedFile!.name,
fileContent: fileContent
})
.subscribe({
next: (res) => {
this.snackBar.open(
`Reconciliation batch processed successfully. ${res.successCount} matched, ${res.mismatchCount} exceptions.`,
'Close',
{ duration: 7000 }
);
this.router.navigate([
'/sponsor-dd-reconciliation/batches',
res.id
]);
},
error: (err) => {
this.loading = false;
this.progressMessage = '';
this.snackBar.open('Reconciliation failed: ' + (err.error?.message || err.message), 'Close', {
duration: 6000
});
}
});
};
reader.readAsDataURL(this.selectedFile); // Reads as Base64 Data URL, which our backend decodes safely.
}
}
@@ -0,0 +1,51 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RecoBatchListComponent } from './reco-batch-list/reco-batch-list.component';
import { RecoBatchUploadComponent } from './reco-batch-upload/reco-batch-upload.component';
import { RecoBatchDetailComponent } from './reco-batch-detail/reco-batch-detail.component';
const routes: Routes = [
Route.withShell([
{
path: 'sponsor-dd-reconciliation',
data: { title: 'DD Reconciliation', breadcrumb: 'DD Reconciliation' },
children: [
{
path: '',
redirectTo: 'batches',
pathMatch: 'full'
},
{
path: 'batches',
component: RecoBatchListComponent,
data: { title: 'Reconciliation Batches', breadcrumb: 'Batches' }
},
{
path: 'upload',
component: RecoBatchUploadComponent,
data: { title: 'Upload Clearing Scroll', breadcrumb: 'Upload' }
},
{
path: 'batches/:batchId',
component: RecoBatchDetailComponent,
data: { title: 'Batch Details', breadcrumb: 'Batch Details' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SponsorDDReconciliationRoutingModule {}
@@ -0,0 +1,23 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { SponsorDDReconciliationRoutingModule } from './sponsor-dd-reconciliation-routing.module';
import { RecoBatchListComponent } from './reco-batch-list/reco-batch-list.component';
import { RecoBatchUploadComponent } from './reco-batch-upload/reco-batch-upload.component';
import { RecoBatchDetailComponent } from './reco-batch-detail/reco-batch-detail.component';
@NgModule({
imports: [
SponsorDDReconciliationRoutingModule,
RecoBatchListComponent,
RecoBatchUploadComponent,
RecoBatchDetailComponent
]
})
export class SponsorDDReconciliationModule {}
@@ -0,0 +1,45 @@
/**
* 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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class SponsorDDReconciliationService {
private http = inject(HttpClient);
getReconciliationBatches(): Observable<any[]> {
return this.http.get<any[]>('/sponsordd/reconcile/batches');
}
getReconciliationBatch(batchId: number): Observable<any> {
return this.http.get<any>(`/sponsordd/reconcile/batches/${batchId}`);
}
getReconciliationRecords(batchId: number): Observable<any[]> {
return this.http.get<any[]>(`/sponsordd/reconcile/batches/${batchId}/records`);
}
uploadClearingScroll(data: { fileName: string; fileContent: string }): Observable<any> {
return this.http.post<any>('/sponsordd/reconcile/upload', data);
}
resolveMismatch(
recordId: number,
data: {
actionType: string; // FORCE_MATCH
resolvedSavingsTxnId?: number;
resolvedCurrentTxnId?: number;
}
): Observable<any> {
return this.http.post<any>(`/sponsordd/reconcile/resolve/${recordId}`, data);
}
}