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
@@ -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',