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.
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
/**
|
|
* 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-issue-approvals',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
RouterModule,
|
|
MatCardModule,
|
|
MatTableModule,
|
|
MatPaginatorModule,
|
|
MatButtonModule,
|
|
MatDialogModule,
|
|
MatSnackBarModule,
|
|
MatFormFieldModule,
|
|
MatInputModule
|
|
],
|
|
templateUrl: './dd-issue-approvals.component.html',
|
|
styles: [
|
|
`
|
|
.approvals-container {
|
|
padding: 24px;
|
|
max-width: 1300px;
|
|
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 DdIssueApprovalsComponent 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',
|
|
'paymentMethod',
|
|
'paymentType',
|
|
'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.getAllIssueRequests().subscribe({
|
|
next: (data) => {
|
|
this.dataSource.data = data;
|
|
this.applyFilter();
|
|
},
|
|
error: (err) => {
|
|
this.snackBar.open('Error loading requests: ' + (err.error?.message || err.message), 'Close', {
|
|
duration: 5000
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
paymentTypeLabel(element: any): string {
|
|
if (element.paymentMethod === 'CASH') {
|
|
return 'Cash';
|
|
}
|
|
if (element.paymentMethod === 'CHEQUE') {
|
|
return 'Cheque';
|
|
}
|
|
if (element.savingsAccountId) {
|
|
return 'Savings';
|
|
}
|
|
if (element.currentAccountId) {
|
|
return 'Current';
|
|
}
|
|
return '-';
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** Every row sharing one batchId was submitted together as one multi-DD slip. */
|
|
batchSiblings(request: any): any[] {
|
|
return request.batchId ? this.dataSource.data.filter((r) => r.batchId === request.batchId) : [request];
|
|
}
|
|
|
|
batchSiblingCount(request: any): number {
|
|
return this.batchSiblings(request).length;
|
|
}
|
|
|
|
approve(request: any): void {
|
|
if (request.batchId) {
|
|
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.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 });
|
|
}
|
|
});
|
|
}
|
|
|
|
openRejectDialog(request: any): void {
|
|
const dialogRef = this.dialog.open(RejectIssueRequestDialogComponent, {
|
|
width: '450px',
|
|
data: { request, siblingCount: this.batchSiblingCount(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-issue-request-dialog',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
ReactiveFormsModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatButtonModule
|
|
],
|
|
template: `
|
|
<h2 mat-dialog-title>
|
|
Reject DD Issuance {{ data.request.batchId ? 'Slip' : 'Request' }} (DD No: {{ data.request.ddNumber
|
|
}}{{ data.siblingCount > 1 ? ' + ' + (data.siblingCount - 1) + ' more' : '' }})
|
|
</h2>
|
|
<mat-dialog-content>
|
|
<p class="hint-text" *ngIf="data.request.batchId">
|
|
This slip has {{ data.siblingCount }} drafts — rejecting applies to all of them together.
|
|
</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')">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%;
|
|
}
|
|
.hint-text {
|
|
color: #64748b;
|
|
font-size: 13px;
|
|
margin: 0 0 12px 0;
|
|
}
|
|
`
|
|
]
|
|
})
|
|
export class RejectIssueRequestDialogComponent implements OnInit {
|
|
private fb = inject(FormBuilder);
|
|
private sponsorDDService = inject(SponsorDDService);
|
|
private dialogRef = inject(MatDialogRef<RejectIssueRequestDialogComponent>);
|
|
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;
|
|
const reason = this.rejectForm.value.rejectionReason;
|
|
const request$ = this.data.request.batchId
|
|
? this.sponsorDDService.rejectIssueRequestBatch(this.data.request.batchId, reason)
|
|
: this.sponsorDDService.rejectIssueRequest(this.data.request.id, reason);
|
|
request$.subscribe({
|
|
next: () => {
|
|
this.snackBar.open(this.data.request.batchId ? 'DD slip rejected' : 'DD issuance 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 });
|
|
}
|
|
});
|
|
}
|
|
}
|