feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
@@ -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 & 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>
|
||||
|
||||
@@ -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 & 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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user