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,107 @@
<!--
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 Reprint Approvals</h2>
<p>
A DD leaflet can only be printed once without approval. Review and approve/reject requests to print an
already-printed leaflet again.
</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>
</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let element">{{ element.reason }}</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>Requested By</th>
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef>Requested 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)">Approve</button>
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">Reject</button>
</div>
<span *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">{{
element.rejectionReason
}}</span>
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
<div>{{ element.consumed ? 'Reprinted' : 'Awaiting print' }}</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 reprint requests match the selected filter.
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
@@ -0,0 +1,322 @@
/**
* 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-reprint-approvals',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './dd-reprint-approvals.component.html',
styles: [
`
.approvals-container {
padding: 24px;
max-width: 1100px;
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 DdReprintApprovalsComponent implements OnInit, AfterViewInit {
private sponsorDDService = inject(SponsorDDService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@ViewChild(MatPaginator) paginator!: MatPaginator;
displayedColumns: string[] = [
'id',
'ddNumber',
'reason',
'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.getAllReprintRequests().subscribe({
next: (data) => {
this.dataSource.data = data;
this.applyFilter();
},
error: (err) => {
this.snackBar.open('Error loading reprint requests: ' + (err.error?.message || err.message), 'Close', {
duration: 5000
});
}
});
}
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();
}
}
approve(request: any): void {
if (!confirm(`Approve reprint of DD No. ${request.ddNumber}? The clerk will then be able to print it once more.`)) {
return;
}
this.sponsorDDService.approveReprintRequest(request.id).subscribe({
next: () => {
this.snackBar.open('Reprint request approved', '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(RejectReprintRequestDialogComponent, {
width: '450px',
data: { 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-reprint-request-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject Reprint Request (DD No: {{ data.request.ddNumber }})</h2>
<mat-dialog-content>
<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%;
}
`
]
})
export class RejectReprintRequestDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private dialogRef = inject(MatDialogRef<RejectReprintRequestDialogComponent>);
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;
this.sponsorDDService.rejectReprintRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({
next: () => {
this.snackBar.open('Reprint 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 });
}
});
}
}