feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
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.dialog
.open(ApproveIssueRequestDialogComponent, {
width: '440px',
maxWidth: '95vw',
data: {
isBatch: false,
ddNumber: request.ddNumber,
beneficiaryName: request.beneficiaryName,
clientName: request.clientName,
totalFaceValue: Number(request.faceValue) || 0,
// Named for how this particular draft is funded, rather than offering the approver every
// possibility and leaving them to work out which one applies.
effect: this.effectTextFor(request)
}
})
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
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 });
}
});
});
}
/** What approving this request actually does, in the words of its own payment method. */
private effectTextFor(request: any, count = 1): string {
const drafts = count > 1 ? `${count} drafts` : 'the draft';
const once = count > 1 ? ' once for the combined total' : '';
switch ((request.paymentMethod || '').toUpperCase()) {
case 'ACCOUNT_DEBIT':
return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
case 'CHEQUE':
return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
default:
return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
}
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 });
}
});
this.dialog
.open(ApproveIssueRequestDialogComponent, {
width: '460px',
maxWidth: '95vw',
data: {
isBatch: true,
clientName: request.clientName,
totalFaceValue: total,
lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })),
effect: this.effectTextFor(request, siblings.length)
}
})
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
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 {
@@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
}
}
/**
* What the approver is about to commit, laid out rather than run together in a sentence.
*
* This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says"
* chrome and no formatting — so the one figure that matters, the amount leaving the customer's
* account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most
* drafts. An approval is the point of no return on someone else's money; it should read like one.
*/
@Component({
selector: 'mifosx-approve-issue-request-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title class="approve-title">
{{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }}
</h2>
<mat-dialog-content>
<div class="approve-summary">
<div class="approve-row">
<span class="approve-label">{{ data.isBatch ? 'Drafts' : 'DD Number' }}</span>
<strong class="approve-value">{{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }}</strong>
</div>
<div class="approve-row" *ngIf="!data.isBatch">
<span class="approve-label">In favour of</span>
<strong class="approve-value">{{ data.beneficiaryName || '—' }}</strong>
</div>
<div class="approve-row" *ngIf="data.clientName">
<span class="approve-label">Purchaser</span>
<strong class="approve-value">{{ data.clientName }}</strong>
</div>
<div class="approve-row approve-row--total">
<span class="approve-label">{{ data.isBatch ? 'Combined face value' : 'Face value' }}</span>
<strong class="approve-amount">₹{{ data.totalFaceValue | number: '1.2-2' }}</strong>
</div>
</div>
<!-- A slip is one debit for every draft on it, which is the part an approver is most likely
to get wrong if it is not said plainly. -->
<ul class="approve-lines" *ngIf="data.isBatch">
<li *ngFor="let l of data.lines">{{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}</li>
</ul>
<p class="approve-effect">
{{ data.effect }}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="dialogRef.close(true)">
{{ data.isBatch ? 'Approve Slip' : 'Approve &amp; Issue' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.approve-title {
margin-bottom: 4px;
}
.approve-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px 14px;
margin-bottom: 12px;
background: #f8fafc;
}
.approve-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid #eef2f7;
font-size: 13px;
}
.approve-row:last-child {
border-bottom: none;
}
.approve-label {
color: #64748b;
}
.approve-value {
text-align: right;
}
.approve-amount {
font-size: 17px;
font-weight: 700;
}
.approve-lines {
margin: 0 0 12px;
padding-left: 20px;
font-size: 12.5px;
color: #334155;
max-height: 160px;
overflow-y: auto;
}
.approve-effect {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
}
`
]
})
export class ApproveIssueRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveIssueRequestDialogComponent>);
data = inject(MAT_DIALOG_DATA);
}
// ── REJECT DIALOG COMPONENT ──
@Component({
selector: 'mifosx-reject-issue-request-dialog',
@@ -209,6 +209,31 @@
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
</span>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint
because the two look alike from the counter: reprint when the paper survived, revoke
when it did not. -->
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingRevokeRequestId"
(click)="openRevokeDialog(element)"
>
Revoke
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where its
draft went, and the blank that took it says what it stands in for. -->
<span class="pending-badge" *ngIf="element.revokedReplacedByDdNumber">
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber">
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</td>
</ng-container>
+135 -1
View File
@@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
background-color: #fef3c7;
color: #b45309;
}
/* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only
with the paper it was printed on, and the draft itself is alive on another leaflet. */
.status-badge.revoked {
background-color: #e2e8f0;
color: #475569;
}
.action-btn-group {
display: flex;
gap: 8px;
@@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit {
'ISSUED',
'CANCELLED',
'STOPPED',
'CLEARED'
'CLEARED',
// Spoiled at the printer. Filterable on its own because it is the count that explains a pad
// whose issued drafts do not add up to the numbers consumed.
'REVOKED'
];
selectedStatus = 'ALL';
searchText = '';
@@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit {
}
});
}
/** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */
openRevokeDialog(leaflet: any): void {
this.dialog
.open(RevokeDdDialogComponent, {
width: '480px',
maxWidth: '95vw',
data: { leaflet },
disableClose: true
})
.afterClosed()
.subscribe((result) => {
if (result) {
this.loadLeaflets();
}
});
}
}
// ── ISSUE DRAFT DIALOG COMPONENT ──
@@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit {
}
}
/**
* Replacing a leaflet ruined at the printer.
*
* Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is
* right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its
* printed number can never be handed to anyone — so the draft moves onto the next blank and both
* leaflets stay on the register, or the pad comes up short against the sponsor bank's range.
*/
@Component({
selector: 'mifosx-revoke-dd-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer.
On approval this leaflet is marked <strong>REVOKED</strong> and the draft is re-issued on the next available
blank.
</p>
<p class="hint-text">
No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already
paid for this draft and is still owed the same one.
</p>
<form [formGroup]="revokeForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>What went wrong with this leaflet?</mat-label>
<textarea matInput formControlName="reason" rows="3"></textarea>
<mat-error *ngIf="revokeForm.get('reason')?.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]="revokeForm.invalid || loading" (click)="onSubmit()">
Submit for Approval
</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 10px 0;
line-height: 1.5;
}
`
]
})
export class RevokeDdDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private dialogRef = inject(MatDialogRef<RevokeDdDialogComponent>);
private snackBar = inject(MatSnackBar);
protected data = inject(MAT_DIALOG_DATA);
revokeForm!: FormGroup;
loading = false;
ngOnInit(): void {
this.revokeForm = this.fb.group({
reason: [
'',
Validators.required
]
});
}
onCancel(): void {
this.dialogRef.close(false);
}
onSubmit(): void {
if (this.revokeForm.invalid) {
return;
}
this.loading = true;
this.sponsorDDService.requestRevoke(this.data.leaflet.id, this.revokeForm.value.reason).subscribe({
next: () => {
this.snackBar.open('Revoke request submitted for approval', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
}
}
// ── PRINT DRAFT DIALOG COMPONENT ──
@Component({
selector: 'mifosx-print-draft-dialog',
@@ -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/.
-->
<div class="approvals-container">
<div class="header-row">
<div class="title-area">
<h2>DD Revoke Approvals</h2>
<p>
A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else.
Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then
any other. No money moves: the customer was already debited.
</p>
</div>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD No. or customer</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($any($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>Spoiled DD</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Purchaser</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="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let element">{{ element.reason }}</td>
</ng-container>
<!-- Empty until approval: no blank is consumed while the request is still pending. -->
<ng-container matColumnDef="replacementDdNumber">
<th mat-header-cell *matHeaderCellDef>Replaced By</th>
<td mat-cell *matCellDef="let element">
<span class="replacement-no" *ngIf="element.replacementDdNumber">{{ element.replacementDdNumber }}</span>
<span class="muted" *ngIf="!element.replacementDdNumber">—</span>
</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="warn" (click)="approve(element)">Approve</button>
<button mat-stroked-button color="warn" (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>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 revoke requests match the selected filter.
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
@@ -0,0 +1,460 @@
/**
* 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, 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';
/**
* The queue for drafts spoiled at the printer.
*
* Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the
* same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke
* spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's
* draft it is, for how much, and that a fresh leaflet will be taken.
*/
@Component({
selector: 'mifosx-dd-revoke-approvals',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './dd-revoke-approvals.component.html',
styles: [
`
.approvals-container {
padding: 24px;
max-width: 1240px;
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;
}
/* The replacement is the whole point of the decision, so it reads as a number rather than
sitting in the row as ordinary text. */
.replacement-no {
font-weight: 600;
color: #15803d;
}
.muted {
color: #94a3b8;
}
`
]
})
export class DdRevokeApprovalsComponent 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',
'reason',
'replacementDdNumber',
'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 needle = this.searchText.trim().toLowerCase();
const searchMatches =
!needle ||
(data.ddNumber || '').toLowerCase().includes(needle) ||
(data.clientName || '').toLowerCase().includes(needle);
return statusMatches && searchMatches;
};
this.loadRequests();
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
}
loadRequests(): void {
this.sponsorDDService.getAllRevokeRequests().subscribe({
next: (data) => {
this.dataSource.data = data || [];
this.applyFilter();
},
error: (err) => {
this.snackBar.open('Error loading revoke 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 {
this.dialog
.open(ApproveRevokeRequestDialogComponent, {
width: '460px',
maxWidth: '95vw',
data: { request },
disableClose: true
})
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.sponsorDDService.approveRevokeRequest(request.id).subscribe({
next: (res: any) => {
// The replacement number is what the clerk has to write on and hand over, so it is said
// plainly rather than left for them to find by reopening the register.
const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : '';
this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 });
}
});
});
}
openRejectDialog(request: any): void {
this.dialog
.open(RejectRevokeRequestDialogComponent, {
width: '450px',
data: { request },
disableClose: true
})
.afterClosed()
.subscribe((result) => {
if (result) {
this.loadRequests();
}
});
}
}
/**
* What approving actually costs, laid out before it is done.
*
* A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed
* together in one sentence under the browser's own "localhost says" chrome. This is a decision
* about someone else's draft and about stock that cannot be replaced, so it reads like one.
*/
@Component({
selector: 'mifosx-approve-revoke-request-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Approve DD Revoke</h2>
<mat-dialog-content>
<div class="rv-summary">
<div class="rv-row">
<span class="rv-label">Spoiled DD</span>
<strong class="rv-value">{{ data.request.ddNumber }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.clientName">
<span class="rv-label">Purchaser</span>
<strong class="rv-value">{{ data.request.clientName }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.beneficiaryName">
<span class="rv-label">In favour of</span>
<strong class="rv-value">{{ data.request.beneficiaryName }}</strong>
</div>
<div class="rv-row rv-row--total">
<span class="rv-label">Face value</span>
<strong class="rv-amount">₹{{ data.request.faceValue | number: '1.2-2' }}</strong>
</div>
</div>
<p class="rv-reason"><span class="rv-label">Reason given</span><br />{{ data.request.reason }}</p>
<p class="rv-effect">
This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank —
preferring this series, then this sponsor bank, then any other. No money moves: the customer was already
debited and is still owed this one draft.
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" (click)="dialogRef.close(true)">Revoke &amp; Re-issue</button>
</mat-dialog-actions>
`,
styles: [
`
.rv-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
background: #f8fafc;
}
.rv-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 4px 0;
}
.rv-row--total {
border-top: 1px solid #e2e8f0;
margin-top: 6px;
padding-top: 8px;
}
.rv-label {
color: #64748b;
font-size: 12.5px;
}
.rv-value {
color: #1e293b;
font-size: 13.5px;
text-align: right;
}
.rv-amount {
color: #1e293b;
font-size: 17px;
font-weight: 700;
}
.rv-reason {
font-size: 13px;
color: #334155;
margin: 0 0 12px;
}
.rv-effect {
font-size: 12.5px;
line-height: 1.55;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
margin: 0;
}
`
]
})
export class ApproveRevokeRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveRevokeRequestDialogComponent>);
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {}
}
// ── REJECT DIALOG ──
@Component({
selector: 'mifosx-reject-revoke-request-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject DD Revoke (DD No: {{ data.request.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over,
so the reason should tell them what to do instead.
</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')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" [disabled]="rejectForm.invalid || loading" (click)="onSubmit()">
Reject
</button>
</mat-dialog-actions>
`,
styles: [
`
.full-width {
width: 100%;
}
.hint-text {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
`
]
})
export class RejectRevokeRequestDialogComponent {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRevokeRequestDialogComponent>);
rejectForm: FormGroup;
loading = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
this.rejectForm = this.fb.group({
rejectionReason: ['', Validators.required]
});
}
onSubmit(): void {
if (this.rejectForm.invalid) {
return;
}
this.loading = true;
this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({
next: () => {
this.loading = false;
this.snackBar.open('Revoke 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: 6000 });
}
});
}
}
@@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.
import { DdListComponent } from './dd-list/dd-list.component';
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component';
import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component';
const routes: Routes = [
Route.withShell([
@@ -44,6 +45,11 @@ const routes: Routes = [
path: 'reprint-approvals',
component: DdReprintApprovalsComponent,
data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' }
},
{
path: 'revoke-approvals',
component: DdRevokeApprovalsComponent,
data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' }
}
]
}
+21
View File
@@ -218,6 +218,27 @@ export class SponsorDDService {
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason });
}
// ── DD revoke requests (maker-checker) ──
// A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves
// onto the next available blank and both leaflets stay on the register — one spoiled, one
// carrying the draft. Distinct from a reprint, which re-prints the same leaflet.
requestRevoke(leafletId: number, reason: string): Observable<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason });
}
getAllRevokeRequests(status?: string): Observable<any[]> {
const params = status ? { status } : {};
return this.http.get<any[]>('/sponsordd/revoke-requests', { params });
}
approveRevokeRequest(id: number): Observable<any> {
return this.http.post<any>(`/sponsordd/revoke-requests/${id}/approve`, {});
}
rejectRevokeRequest(id: number, rejectionReason: string): Observable<any> {
return this.http.post<any>(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason });
}
// ── DD amendment requests (maker-checker) ──
// Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else
// is amendable: the money behind the face value has already moved, so a draft wrong in that way