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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user