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,166 @@
<!--
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="approvals-container">
<div class="header-row">
<div class="title-area">
<h2>DD Issuance Approvals</h2>
<p>
Review and approve/reject DD issuance requests submitted by clerks. Approving posts the real GL entries and
issues the draft.
</p>
</div>
<button mat-stroked-button (click)="openSignatureSettings()">My Signature</button>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD Leaflet No.</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
</mat-form-field>
<div class="status-filter-row">
<button
mat-stroked-button
*ngFor="let status of statusOptions"
[color]="selectedStatus === status ? 'primary' : undefined"
[class.filter-active]="selectedStatus === status"
(click)="filterByStatus(status)"
>
{{ status === 'ALL' ? 'All' : status }}
<span class="filter-count">({{ statusCount(status) }})</span>
</button>
</div>
<mat-card>
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Req. ID</th>
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
</ng-container>
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD No.</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
<div *ngIf="element.batchId" style="color: #7c3aed; font-size: 11px">
Slip · {{ batchSiblingCount(element) }} drafts
</div>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let element">{{ element.clientName }}</td>
</ng-container>
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName }}</td>
</ng-container>
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef>Face Value</th>
<td mat-cell *matCellDef="let element">{{ element.faceValue | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="paymentMethod">
<th mat-header-cell *matHeaderCellDef>Payment Method</th>
<td mat-cell *matCellDef="let element">
<ng-container [ngSwitch]="element.paymentMethod">
<span *ngSwitchCase="'CHEQUE'">Cheque {{ element.chequeNumber || '' }}</span>
<span *ngSwitchCase="'CASH'"
>Cash (Walk-in{{ element.walkinPurchaserName ? ': ' + element.walkinPurchaserName : '' }})</span
>
<span *ngSwitchDefault>Account Debit</span>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="paymentType">
<th mat-header-cell *matHeaderCellDef>Payment Type</th>
<td mat-cell *matCellDef="let element">{{ paymentTypeLabel(element) }}</td>
</ng-container>
<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 }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
<button mat-flat-button color="primary" size="small" (click)="approve(element)">
{{ element.batchId ? 'Approve Slip' : 'Approve' }}
</button>
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">
{{ element.batchId ? 'Reject Slip' : 'Reject' }}
</button>
</div>
<div
*ngIf="element.status === 'PENDING' && element.batchId"
style="color: #7c3aed; font-size: 11px; margin-top: 4px"
>
Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip.
</div>
<div
*ngIf="element.status === 'PENDING' && element.cashReceiptJournalEntryId"
style="color: #64748b; font-size: 11px; margin-top: 4px"
>
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }} (pending approval)
</div>
<div *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">
<div>{{ element.rejectionReason }}</div>
<div *ngIf="element.cashRefundJournalEntryId">
Cash refunded to purchaser: GL entry #{{ element.cashRefundJournalEntryId }}
</div>
</div>
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
<div *ngIf="element.resultingSavingsTransactionId">
Savings txn #{{ element.resultingSavingsTransactionId }}
</div>
<div *ngIf="element.resultingCurrentTransactionId">
Current txn #{{ element.resultingCurrentTransactionId }}
</div>
<div *ngIf="element.cashReceiptJournalEntryId">
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }}
</div>
<div *ngIf="element.resultingJournalEntryId">GL entry #{{ element.resultingJournalEntryId }}</div>
<div>by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}</div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.filteredData.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No DD issuance requests match the selected filter.
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
@@ -0,0 +1,399 @@
/**
* 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, AfterViewInit, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { SponsorDDService } from '../sponsor-dd.service';
import { SignatureSettingsDialogComponent } from '../signature-settings-dialog/signature-settings-dialog.component';
@Component({
selector: 'mifosx-dd-issue-approvals',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './dd-issue-approvals.component.html',
styles: [
`
.approvals-container {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
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;
}
.search-field {
width: 320px;
display: block;
margin-bottom: 8px;
}
.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.pending {
background-color: #fef3c7;
color: #b45309;
}
.status-badge.approved {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.rejected {
background-color: #fee2e2;
color: #b91c1c;
}
.action-btn-group {
display: flex;
gap: 8px;
}
.status-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.status-filter-row button.filter-active {
background-color: #ede9fe;
}
.filter-count {
color: #64748b;
margin-left: 4px;
}
`
]
})
export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
private sponsorDDService = inject(SponsorDDService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@ViewChild(MatPaginator) paginator!: MatPaginator;
displayedColumns: string[] = [
'id',
'ddNumber',
'clientName',
'beneficiaryName',
'faceValue',
'paymentMethod',
'paymentType',
'status',
'makerUsername',
'createdDate',
'actions'
];
dataSource = new MatTableDataSource<any>([]);
readonly statusOptions: string[] = [
'PENDING',
'APPROVED',
'REJECTED',
'ALL'
];
selectedStatus = 'PENDING';
searchText = '';
ngOnInit(): void {
this.dataSource.filterPredicate = (data) => {
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
const searchMatches =
!this.searchText || (data.ddNumber || '').toLowerCase().includes(this.searchText.trim().toLowerCase());
return statusMatches && searchMatches;
};
this.loadRequests();
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
}
loadRequests(): void {
this.sponsorDDService.getAllIssueRequests().subscribe({
next: (data) => {
this.dataSource.data = data;
this.applyFilter();
},
error: (err) => {
this.snackBar.open('Error loading requests: ' + (err.error?.message || err.message), 'Close', {
duration: 5000
});
}
});
}
paymentTypeLabel(element: any): string {
if (element.paymentMethod === 'CASH') {
return 'Cash';
}
if (element.paymentMethod === 'CHEQUE') {
return 'Cheque';
}
if (element.savingsAccountId) {
return 'Savings';
}
if (element.currentAccountId) {
return 'Current';
}
return '-';
}
statusCount(status: string): number {
const all = this.dataSource.data;
return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length;
}
filterByStatus(status: string): void {
this.selectedStatus = status;
this.applyFilter();
}
onSearchChange(value: string): void {
this.searchText = value;
this.applyFilter();
}
private applyFilter(): void {
this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`;
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
/** Every row sharing one batchId was submitted together as one multi-DD slip. */
batchSiblings(request: any): any[] {
return request.batchId ? this.dataSource.data.filter((r) => r.batchId === request.batchId) : [request];
}
batchSiblingCount(request: any): number {
return this.batchSiblings(request).length;
}
approve(request: any): void {
if (request.batchId) {
this.approveBatch(request);
return;
}
if (
!confirm(
`Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.`
)
) {
return;
}
this.sponsorDDService.approveIssueRequest(request.id).subscribe({
next: () => {
this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
}
private approveBatch(request: any): void {
const siblings = this.batchSiblings(request);
const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n');
const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0);
if (
!confirm(
`Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` +
'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.'
)
) {
return;
}
this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
next: () => {
this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
}
openRejectDialog(request: any): void {
const dialogRef = this.dialog.open(RejectIssueRequestDialogComponent, {
width: '450px',
data: { request, siblingCount: this.batchSiblingCount(request) },
disableClose: true
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.loadRequests();
}
});
}
openSignatureSettings(): void {
this.dialog.open(SignatureSettingsDialogComponent, {
width: '450px'
});
}
}
// ── REJECT DIALOG COMPONENT ──
@Component({
selector: 'mifosx-reject-issue-request-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>
Reject DD Issuance {{ data.request.batchId ? 'Slip' : 'Request' }} (DD No: {{ data.request.ddNumber
}}{{ data.siblingCount > 1 ? ' + ' + (data.siblingCount - 1) + ' more' : '' }})
</h2>
<mat-dialog-content>
<p class="hint-text" *ngIf="data.request.batchId">
This slip has {{ data.siblingCount }} drafts — rejecting applies to all of them together.
</p>
<form [formGroup]="rejectForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Rejection Reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="rejectForm.get('rejectionReason')?.hasError('required')">Reason is 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="warn" [disabled]="rejectForm.invalid || loading" (click)="onSubmit()">
Confirm Rejection
</button>
</mat-dialog-actions>
`,
styles: [
`
.dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
}
.full-width {
width: 100%;
}
.hint-text {
color: #64748b;
font-size: 13px;
margin: 0 0 12px 0;
}
`
]
})
export class RejectIssueRequestDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private dialogRef = inject(MatDialogRef<RejectIssueRequestDialogComponent>);
private snackBar = inject(MatSnackBar);
protected data = inject(MAT_DIALOG_DATA);
rejectForm!: FormGroup;
loading = false;
ngOnInit(): void {
this.rejectForm = this.fb.group({
rejectionReason: [
'',
Validators.required
]
});
}
onCancel(): void {
this.dialogRef.close(false);
}
onSubmit(): void {
if (this.rejectForm.invalid) return;
this.loading = true;
const reason = this.rejectForm.value.rejectionReason;
const request$ = this.data.request.batchId
? this.sponsorDDService.rejectIssueRequestBatch(this.data.request.batchId, reason)
: this.sponsorDDService.rejectIssueRequest(this.data.request.id, reason);
request$.subscribe({
next: () => {
this.snackBar.open(this.data.request.batchId ? 'DD slip rejected' : 'DD issuance request rejected', 'Close', {
duration: 3000
});
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
}
});
}
}