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,101 @@
|
||||
<!--
|
||||
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="inventory-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>Sponsor Bank DD Inventory</h2>
|
||||
<p>Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- ID Column -->
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Sponsor Bank Column -->
|
||||
<ng-container matColumnDef="sponsorBankName">
|
||||
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.sponsorBankName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Branch Column -->
|
||||
<ng-container matColumnDef="branchName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Series Start Column -->
|
||||
<ng-container matColumnDef="seriesStart">
|
||||
<th mat-header-cell *matHeaderCellDef>Start No.</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.seriesStart }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Series End Column -->
|
||||
<ng-container matColumnDef="seriesEnd">
|
||||
<th mat-header-cell *matHeaderCellDef>End No.</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.seriesEnd }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Total Leaves Column -->
|
||||
<ng-container matColumnDef="totalLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef>Total Leaves</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.totalLeaves }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Available Leaves Column -->
|
||||
<ng-container matColumnDef="availableLeaves">
|
||||
<th mat-header-cell *matHeaderCellDef>Available Leaves</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span [class.leaves-low]="element.availableLeaves === 0">{{ element.availableLeaves }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Received Date Column -->
|
||||
<ng-container matColumnDef="receivedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Received Date</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.receivedDate }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<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>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd/leaflets', element.id]">
|
||||
View Leaflets
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.length === 0"
|
||||
class="no-records-row"
|
||||
style="text-align: center; padding: 32px; color: #64748b"
|
||||
>
|
||||
No DD series registered yet. Click "Register DD Series" to add stock.
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule, DatePipe } 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 } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dd-inventory-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './dd-inventory-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
.inventory-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
.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.active {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.exhausted {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.action-btn {
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
.leaves-low {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class DdInventoryListComponent implements OnInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'sponsorBankName',
|
||||
'branchName',
|
||||
'seriesStart',
|
||||
'seriesEnd',
|
||||
'totalLeaves',
|
||||
'availableLeaves',
|
||||
'receivedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadInventories();
|
||||
}
|
||||
|
||||
loadInventories(): void {
|
||||
this.sponsorDDService.getInventories().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource = data;
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAddInventoryDialog(): void {
|
||||
const dialogRef = this.dialog.open(AddInventoryDialogComponent, {
|
||||
width: '500px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadInventories();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD INVENTORY DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-add-inventory-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatButtonModule
|
||||
],
|
||||
providers: [DatePipe],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add Sponsor DD Inventory</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="inventoryForm" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Sponsor Bank</mat-label>
|
||||
<mat-select formControlName="sponsorBankId">
|
||||
<mat-option *ngFor="let sb of sponsorBanks" [value]="sb.id">{{ sb.name }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Don't see your sponsor bank? Add it under Organization > Sponsor Banks.</mat-hint>
|
||||
<mat-error *ngIf="inventoryForm.get('sponsorBankId')?.hasError('required')"
|
||||
>Sponsor Bank is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select formControlName="branchId">
|
||||
<mat-option *ngFor="let branch of branches" [value]="branch.id">{{ branch.name }}</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="inventoryForm.get('branchId')?.hasError('required')">Branch is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Series Start</mat-label>
|
||||
<input matInput type="number" formControlName="seriesStart" />
|
||||
<mat-error *ngIf="inventoryForm.get('seriesStart')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Series End</mat-label>
|
||||
<input matInput type="number" formControlName="seriesEnd" />
|
||||
<mat-error *ngIf="inventoryForm.get('seriesEnd')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Received Date</mat-label>
|
||||
<input matInput [matDatepicker]="picker" formControlName="receivedDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
<mat-error *ngIf="inventoryForm.get('receivedDate')?.hasError('required')"
|
||||
>Received Date 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="primary" [disabled]="inventoryForm.invalid || loading" (click)="onSubmit()">
|
||||
Save
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AddInventoryDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private http = inject(HttpClient);
|
||||
private datePipe = inject(DatePipe);
|
||||
private dialogRef = inject(MatDialogRef<AddInventoryDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
inventoryForm!: FormGroup;
|
||||
loading = false;
|
||||
sponsorBanks: any[] = [];
|
||||
branches: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inventoryForm = this.fb.group({
|
||||
sponsorBankId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
branchId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
seriesStart: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1)
|
||||
]
|
||||
],
|
||||
seriesEnd: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1)
|
||||
]
|
||||
],
|
||||
receivedDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.loadSponsorBanks();
|
||||
this.loadBranches();
|
||||
}
|
||||
|
||||
loadSponsorBanks(): void {
|
||||
this.http.get<any[]>('/sponsorbanks').subscribe({
|
||||
next: (data) => {
|
||||
this.sponsorBanks = data.map((sb) => ({ id: sb.id, name: sb.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches(): void {
|
||||
this.http.get<any[]>('/offices').subscribe({
|
||||
next: (data) => {
|
||||
this.branches = data.map((b) => ({ id: b.id, name: b.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.inventoryForm.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
const formVal = this.inventoryForm.value;
|
||||
const formattedDate = this.datePipe.transform(formVal.receivedDate, 'yyyy-MM-dd') || '';
|
||||
|
||||
const payload = {
|
||||
sponsorBankId: formVal.sponsorBankId,
|
||||
branchId: formVal.branchId,
|
||||
seriesStart: formVal.seriesStart,
|
||||
seriesEnd: formVal.seriesEnd,
|
||||
receivedDate: formattedDate
|
||||
};
|
||||
|
||||
this.sponsorDDService.registerInventory(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Inventory added successfully', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
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 Issuance Approvals</h2>
|
||||
<p>
|
||||
Review and approve/reject DD issuance requests submitted by clerks. Approving posts the real GL entries and
|
||||
issues the draft.
|
||||
</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>
|
||||
<div *ngIf="element.batchId" style="color: #7c3aed; font-size: 11px">
|
||||
Slip · {{ batchSiblingCount(element) }} drafts
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="clientName">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</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="paymentMethod">
|
||||
<th mat-header-cell *matHeaderCellDef>Payment Method</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<ng-container [ngSwitch]="element.paymentMethod">
|
||||
<span *ngSwitchCase="'CHEQUE'">Cheque {{ element.chequeNumber || '' }}</span>
|
||||
<span *ngSwitchCase="'CASH'"
|
||||
>Cash (Walk-in{{ element.walkinPurchaserName ? ': ' + element.walkinPurchaserName : '' }})</span
|
||||
>
|
||||
<span *ngSwitchDefault>Account Debit</span>
|
||||
</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="paymentType">
|
||||
<th mat-header-cell *matHeaderCellDef>Payment Type</th>
|
||||
<td mat-cell *matCellDef="let element">{{ paymentTypeLabel(element) }}</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>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted 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)">
|
||||
{{ element.batchId ? 'Approve Slip' : 'Approve' }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">
|
||||
{{ element.batchId ? 'Reject Slip' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.batchId"
|
||||
style="color: #7c3aed; font-size: 11px; margin-top: 4px"
|
||||
>
|
||||
Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip.
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.cashReceiptJournalEntryId"
|
||||
style="color: #64748b; font-size: 11px; margin-top: 4px"
|
||||
>
|
||||
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }} (pending approval)
|
||||
</div>
|
||||
<div *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">
|
||||
<div>{{ element.rejectionReason }}</div>
|
||||
<div *ngIf="element.cashRefundJournalEntryId">
|
||||
Cash refunded to purchaser: GL entry #{{ element.cashRefundJournalEntryId }}
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
|
||||
<div *ngIf="element.resultingSavingsTransactionId">
|
||||
Savings txn #{{ element.resultingSavingsTransactionId }}
|
||||
</div>
|
||||
<div *ngIf="element.resultingCurrentTransactionId">
|
||||
Current txn #{{ element.resultingCurrentTransactionId }}
|
||||
</div>
|
||||
<div *ngIf="element.cashReceiptJournalEntryId">
|
||||
Cash received: GL entry #{{ element.cashReceiptJournalEntryId }}
|
||||
</div>
|
||||
<div *ngIf="element.resultingJournalEntryId">GL entry #{{ element.resultingJournalEntryId }}</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 issuance requests match the selected filter.
|
||||
</div>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
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="leaflet-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<button mat-button routerLink="/sponsor-dd/inventory" style="margin-bottom: 8px; color: #64748b; padding-left: 0">
|
||||
← Back to Inventories
|
||||
</button>
|
||||
<h2>DD Leaflet Registry</h2>
|
||||
<p *ngIf="inventoryDetails">
|
||||
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range:
|
||||
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</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 color="accent" [class.filter-active]="todayOnly" (click)="toggleTodayOnly()">
|
||||
{{ todayOnly ? "Today's Issued DDs Only" : 'Showing All Dates' }}
|
||||
</button>
|
||||
<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>
|
||||
<p class="hint-text" *ngIf="todayOnly">
|
||||
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All
|
||||
Dates" above to see everything.
|
||||
</p>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- DD Number Column -->
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<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>
|
||||
|
||||
<!-- Face Value Column -->
|
||||
<ng-container matColumnDef="faceValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Face Value</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Client Name Column -->
|
||||
<ng-container matColumnDef="issuedToClientName">
|
||||
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.issuedToClientName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Beneficiary Column -->
|
||||
<ng-container matColumnDef="beneficiaryName">
|
||||
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Issued Date Column -->
|
||||
<ng-container matColumnDef="issuedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.issuedDate || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Advice Status Column -->
|
||||
<ng-container matColumnDef="adviceStatus">
|
||||
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.status === 'ISSUED' ? element.adviceStatus : '-' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="action-btn-group">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
size="small"
|
||||
*ngIf="element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
|
||||
(click)="openIssueDialog(element)"
|
||||
>
|
||||
Issue Draft
|
||||
</button>
|
||||
<span class="pending-badge" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
|
||||
Pending Approval (Req #{{ element.pendingIssueRequestId }})
|
||||
</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED'"
|
||||
(click)="openCancelDialog(element)"
|
||||
>
|
||||
Cancel Draft
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="warn"
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED'"
|
||||
(click)="onStopPayment(element)"
|
||||
>
|
||||
Stop Payment
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
size="small"
|
||||
*ngIf="element.status === 'ISSUED' && !element.printCount"
|
||||
(click)="openPrintDialog(element)"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
size="small"
|
||||
*ngIf="
|
||||
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
|
||||
"
|
||||
(click)="openPrintDialog(element)"
|
||||
>
|
||||
Print (Reprint Approved)
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
size="small"
|
||||
*ngIf="
|
||||
element.status === 'ISSUED' &&
|
||||
element.printCount > 0 &&
|
||||
!element.approvedUnconsumedReprintRequestId &&
|
||||
!element.pendingReprintRequestId
|
||||
"
|
||||
(click)="openRequestReprintDialog(element)"
|
||||
>
|
||||
Request Reprint
|
||||
</button>
|
||||
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
|
||||
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
{{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }}
|
||||
</div>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-gl-settings-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>GST / GL Settings</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
GST is charged on the DD issuance commission/charges only, never on the DD face value. The rate below applies to
|
||||
every DD submitted from now on - it's locked in per-request at submission time, so changing it here never
|
||||
affects requests already in flight.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="dialog-form" *ngIf="!loading">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>GST Percentage (%)</mat-label>
|
||||
<input matInput type="number" formControlName="gstPercentage" step="0.01" />
|
||||
<mat-error *ngIf="form.get('gstPercentage')?.hasError('required')">Required</mat-error>
|
||||
<mat-error *ngIf="form.get('gstPercentage')?.hasError('min') || form.get('gstPercentage')?.hasError('max')">
|
||||
Must be between 0 and 100
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Limit Amount</mat-label>
|
||||
<input matInput type="number" formControlName="ddLimitAmount" step="0.01" />
|
||||
<mat-hint>
|
||||
Maximum face value for a single DD, and the maximum combined face value of all DDs issued to the same
|
||||
beneficiary on the same day. Leave blank for no limit.
|
||||
</mat-hint>
|
||||
<mat-error *ngIf="form.get('ddLimitAmount')?.hasError('min')">Cannot be negative</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="readonly-accounts">
|
||||
<div class="account-row">
|
||||
<span>DD Payable</span><strong>{{ mapping?.ddPayableGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cheque Clearing</span><strong>{{ mapping?.chequeClearingGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cash</span><strong>{{ mapping?.cashGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Cash (Pending Approval)</span><strong>{{ mapping?.cashSuspenseGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>Charges Income</span><strong>{{ mapping?.chargesIncomeGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>GST Payable</span><strong>{{ mapping?.gstPayableGlAccountName || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-text">
|
||||
GL account assignments above are set up once by an administrator and shown here read-only.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading || form.invalid || saving" (click)="onSave()">
|
||||
Save
|
||||
</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;
|
||||
}
|
||||
.readonly-accounts {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.account-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 4px 0;
|
||||
color: #334155;
|
||||
}
|
||||
.account-row span {
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class GlSettingsDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialogRef = inject(MatDialogRef<GlSettingsDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
form!: FormGroup;
|
||||
loading = true;
|
||||
saving = false;
|
||||
mapping: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
gstPercentage: [
|
||||
18,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
]
|
||||
],
|
||||
ddLimitAmount: [
|
||||
null,
|
||||
[Validators.min(0)]
|
||||
]
|
||||
});
|
||||
this.sponsorDDService.getGlMappings().subscribe({
|
||||
next: (mappings) => {
|
||||
this.mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
if (this.mapping) {
|
||||
this.form.patchValue({
|
||||
gstPercentage: this.mapping.gstPercentage ?? 18,
|
||||
ddLimitAmount: this.mapping.ddLimitAmount ?? null
|
||||
});
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error loading GL settings: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.form.invalid || !this.mapping) return;
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService
|
||||
.upsertGlMapping({
|
||||
sponsorBankId: 0,
|
||||
ddPayableGlAccountId: this.mapping.ddPayableGlAccountId,
|
||||
chequeClearingGlAccountId: this.mapping.chequeClearingGlAccountId,
|
||||
cashGlAccountId: this.mapping.cashGlAccountId,
|
||||
chargesIncomeGlAccountId: this.mapping.chargesIncomeGlAccountId,
|
||||
cashSuspenseGlAccountId: this.mapping.cashSuspenseGlAccountId,
|
||||
gstPercentage: this.form.value.gstPercentage,
|
||||
gstPayableGlAccountId: this.mapping.gstPayableGlAccountId,
|
||||
ddLimitAmount: this.form.value.ddLimitAmount
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('GST percentage updated', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-signature-settings-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>My Signature</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
Upload your signature once - it gets stamped onto any Sponsor DD you approve when it's printed. Only used for
|
||||
DDs you personally approve; other approvers use their own.
|
||||
</p>
|
||||
|
||||
<div class="signature-preview" *ngIf="signatureDataUri">
|
||||
<img [src]="signatureDataUri" alt="Current signature" />
|
||||
</div>
|
||||
<p *ngIf="!loading && !signatureDataUri" class="no-signature">No signature uploaded yet.</p>
|
||||
|
||||
<input type="file" accept="image/png,image/jpeg" #fileInput hidden (change)="onFileSelected($event)" />
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-stroked-button color="warn" *ngIf="signatureDataUri" [disabled]="loading" (click)="onRemove()">
|
||||
Remove
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading" (click)="fileInput.click()">
|
||||
{{ signatureDataUri ? 'Replace' : 'Upload' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
.signature-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed #94a3b8;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.signature-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
}
|
||||
.no-signature {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SignatureSettingsDialogComponent implements OnInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private dialogRef = inject(MatDialogRef<SignatureSettingsDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
loading = false;
|
||||
signatureDataUri: string | null = null;
|
||||
private existingDocumentId: number | null = null;
|
||||
private userId!: number;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userId = Number(this.authenticationService.getCredentials()?.userId);
|
||||
this.loadSignature();
|
||||
}
|
||||
|
||||
private loadSignature(): void {
|
||||
this.loading = true;
|
||||
this.sponsorDDService.findSignatureDocument(this.userId).subscribe({
|
||||
next: (doc) => {
|
||||
this.existingDocumentId = doc ? doc.id : null;
|
||||
if (doc) {
|
||||
this.sponsorDDService.getSignatureDataUri(this.userId).subscribe({
|
||||
next: (dataUri) => {
|
||||
this.signatureDataUri = dataUri;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => (this.loading = false)
|
||||
});
|
||||
} else {
|
||||
this.signatureDataUri = null;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
error: () => (this.loading = false)
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
this.loading = true;
|
||||
const afterOldRemoved = this.existingDocumentId
|
||||
? this.sponsorDDService.deleteSignature(this.userId, this.existingDocumentId)
|
||||
: null;
|
||||
|
||||
const upload = () => {
|
||||
this.sponsorDDService.uploadSignature(this.userId, file).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Signature uploaded', 'Close', { duration: 3000 });
|
||||
this.loadSignature();
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (afterOldRemoved) {
|
||||
afterOldRemoved.subscribe({ next: upload, error: upload });
|
||||
} else {
|
||||
upload();
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (!this.existingDocumentId || !confirm('Remove your uploaded signature?')) return;
|
||||
|
||||
this.loading = true;
|
||||
this.sponsorDDService.deleteSignature(this.userId, this.existingDocumentId).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Signature removed', 'Close', { duration: 3000 });
|
||||
this.existingDocumentId = null;
|
||||
this.signatureDataUri = null;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } 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';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-sponsor-bank-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<div class="sponsor-bank-container">
|
||||
<div class="header-row">
|
||||
<div class="title-area">
|
||||
<h2>Sponsor Banks</h2>
|
||||
<p>Banks on whose behalf your bank issues demand drafts. Used when registering DD inventory.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="openAddDialog()">+ Add Sponsor Bank</button>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.name }}</strong>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="description">
|
||||
<th mat-header-cell *matHeaderCellDef>Description</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.description || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="ifscCode">
|
||||
<th mat-header-cell *matHeaderCellDef>IFSC Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.ifscCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="bankCode">
|
||||
<th mat-header-cell *matHeaderCellDef>Bank Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.bankCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="swiftCode">
|
||||
<th mat-header-cell *matHeaderCellDef>SWIFT Code</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.swiftCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<div *ngIf="dataSource.length === 0" class="no-records-row">No sponsor banks registered yet.</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.sponsor-bank-container {
|
||||
padding: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.no-records-row {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SponsorBankListComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'ifscCode',
|
||||
'bankCode',
|
||||
'swiftCode'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSponsorBanks();
|
||||
}
|
||||
|
||||
loadSponsorBanks(): void {
|
||||
this.http.get<any[]>('/sponsorbanks').subscribe({
|
||||
next: (data) => (this.dataSource = data),
|
||||
error: (err) => {
|
||||
this.snackBar.open('Error loading sponsor banks: ' + (err.error?.message || err.message), 'Close', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openAddDialog(): void {
|
||||
const dialogRef = this.dialog.open(AddSponsorBankDialogComponent, {
|
||||
width: '450px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadSponsorBanks();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD SPONSOR BANK DIALOG COMPONENT ──
|
||||
@Component({
|
||||
selector: 'mifosx-add-sponsor-bank-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add Sponsor Bank</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="form" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Sponsor Bank Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g. State Bank of India" />
|
||||
<mat-error *ngIf="form.get('name')?.hasError('required')">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<input matInput formControlName="description" placeholder="e.g. Sponsor Bank" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>IFSC Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="ifscCode"
|
||||
placeholder="e.g. SBIN0001234"
|
||||
maxlength="11"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-hint>Settlement branch's IFSC - 4 letters, '0', then 6 alphanumeric characters</mat-hint>
|
||||
<mat-error *ngIf="form.get('ifscCode')?.hasError('pattern')"
|
||||
>Invalid IFSC format (e.g. SBIN0001234)</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Bank Code</mat-label>
|
||||
<input matInput formControlName="bankCode" placeholder="e.g. SBIN" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>SWIFT Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="swiftCode"
|
||||
placeholder="e.g. SBININBB104"
|
||||
maxlength="11"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid || loading" (click)="onSubmit()">Save</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AddSponsorBankDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
private dialogRef = inject(MatDialogRef<AddSponsorBankDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
name: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
description: ['Sponsor Bank'],
|
||||
ifscCode: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Za-z]{4}0[A-Za-z0-9]{6}$/)
|
||||
],
|
||||
bankCode: [''],
|
||||
swiftCode: ['']
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
this.http.post<any>('/sponsorbanks', this.form.value).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Sponsor Bank added successfully', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.component';
|
||||
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';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'sponsor-dd',
|
||||
data: { title: 'Sponsor DD', breadcrumb: 'Sponsor DD' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'inventory',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'inventory',
|
||||
component: DdInventoryListComponent,
|
||||
data: { title: 'DD Inventory', breadcrumb: 'Inventory' }
|
||||
},
|
||||
{
|
||||
path: 'leaflets/:inventoryId',
|
||||
component: DdListComponent,
|
||||
data: { title: 'DD Leaflets', breadcrumb: 'Leaflets' }
|
||||
},
|
||||
{
|
||||
path: 'issue-approvals',
|
||||
component: DdIssueApprovalsComponent,
|
||||
data: { title: 'DD Issuance Approvals', breadcrumb: 'Issuance Approvals' }
|
||||
},
|
||||
{
|
||||
path: 'reprint-approvals',
|
||||
component: DdReprintApprovalsComponent,
|
||||
data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SponsorDDRoutingModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { SponsorDDRoutingModule } from './sponsor-dd-routing.module';
|
||||
import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.component';
|
||||
import { DdListComponent } from './dd-list/dd-list.component';
|
||||
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SponsorDDRoutingModule,
|
||||
DdInventoryListComponent,
|
||||
DdListComponent,
|
||||
DdIssueApprovalsComponent
|
||||
]
|
||||
})
|
||||
export class SponsorDDModule {}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SponsorDDService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ── Inventory ──
|
||||
getInventories(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/inventory');
|
||||
}
|
||||
|
||||
getInventoryById(id: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/inventory/${id}`);
|
||||
}
|
||||
|
||||
registerInventory(data: {
|
||||
sponsorBankId: number;
|
||||
branchId: number;
|
||||
seriesStart: number;
|
||||
seriesEnd: number;
|
||||
receivedDate: string; // yyyy-MM-dd
|
||||
}): Observable<any> {
|
||||
return this.http.post<any>('/sponsordd/inventory', data);
|
||||
}
|
||||
|
||||
// ── Leaflets ──
|
||||
getLeafletsByInventory(inventoryId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/sponsordd/inventory/${inventoryId}/leaflets`);
|
||||
}
|
||||
|
||||
getLeafletById(leafletId: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/leaflets/${leafletId}`);
|
||||
}
|
||||
|
||||
getLeafletByDdNumber(ddNumber: string): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/leaflets/by-number/${ddNumber}`);
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
issueDemandDraft(
|
||||
leafletId: number,
|
||||
data: {
|
||||
clientId: number;
|
||||
beneficiaryName: string;
|
||||
faceValue: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
savingsTransactionId?: number;
|
||||
currentTransactionId?: number;
|
||||
journalEntryId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue`, data);
|
||||
}
|
||||
|
||||
cancelDemandDraft(
|
||||
leafletId: number,
|
||||
data: {
|
||||
remarks: string;
|
||||
refundSavingsTransactionId?: number;
|
||||
refundCurrentTransactionId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/cancel`, data);
|
||||
}
|
||||
|
||||
stopPaymentDemandDraft(leafletId: number, data: { remarks: string }): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/stop`, data);
|
||||
}
|
||||
|
||||
// ── DD Issuance Requests (maker-checker) ──
|
||||
submitIssueRequest(
|
||||
leafletId: number,
|
||||
data: {
|
||||
clientId?: number;
|
||||
walkinPurchaserName?: string;
|
||||
walkinPurchaserAddress?: string;
|
||||
walkinPurchaserAadhaar?: string;
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH';
|
||||
savingsAccountId?: number;
|
||||
currentAccountId?: number;
|
||||
chequeNumber?: string;
|
||||
chequeBankName?: string;
|
||||
cashierDepositRequestId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data);
|
||||
}
|
||||
|
||||
getAvailableCashierDepositRequests(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/cashier-deposit-requests/available');
|
||||
}
|
||||
|
||||
// ── Multi-DD slip (one account debit/cheque funds several drafts) ──
|
||||
submitIssueRequestBatch(
|
||||
inventoryId: number,
|
||||
data: {
|
||||
clientId?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE';
|
||||
savingsAccountId?: number;
|
||||
currentAccountId?: number;
|
||||
chequeNumber?: string;
|
||||
chequeBankName?: string;
|
||||
lines: {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount?: number;
|
||||
}[];
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data);
|
||||
}
|
||||
|
||||
approveIssueRequestBatch(batchId: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/approve`, {});
|
||||
}
|
||||
|
||||
rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── Sponsor DD GL mapping (back-office configuration) ──
|
||||
getGlMappings(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/gl-mapping');
|
||||
}
|
||||
|
||||
upsertGlMapping(data: {
|
||||
sponsorBankId: number;
|
||||
ddPayableGlAccountId: number;
|
||||
chequeClearingGlAccountId?: number;
|
||||
cashGlAccountId?: number;
|
||||
chargesIncomeGlAccountId?: number;
|
||||
cashSuspenseGlAccountId?: number;
|
||||
gstPercentage?: number;
|
||||
gstPayableGlAccountId?: number;
|
||||
ddLimitAmount?: number | null;
|
||||
}): Observable<any> {
|
||||
return this.http.post<any>('/sponsordd/gl-mapping', data);
|
||||
}
|
||||
|
||||
getAllIssueRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? { status } : {};
|
||||
return this.http.get<any[]>('/sponsordd/issue-requests', { params });
|
||||
}
|
||||
|
||||
getMyIssueRequests(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/sponsordd/issue-requests/mine');
|
||||
}
|
||||
|
||||
getIssueRequestById(id: number): Observable<any> {
|
||||
return this.http.get<any>(`/sponsordd/issue-requests/${id}`);
|
||||
}
|
||||
|
||||
approveIssueRequest(id: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
rejectIssueRequest(id: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/${id}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── DD reprint requests (maker-checker) ──
|
||||
recordPrint(leafletId: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/print`, {});
|
||||
}
|
||||
|
||||
requestReprint(leafletId: number, reason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/reprint-request`, { reason });
|
||||
}
|
||||
|
||||
getAllReprintRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? { status } : {};
|
||||
return this.http.get<any[]>('/sponsordd/reprint-requests', { params });
|
||||
}
|
||||
|
||||
approveReprintRequest(id: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
rejectReprintRequest(id: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
// ── Approver signature (stamped onto the printed DD) ──
|
||||
// Reuses Fineract's generic document-attachment API (entityType is a free-form string with
|
||||
// no per-type backend wiring required) rather than building dedicated signature storage.
|
||||
private static readonly SIGNATURE_ENTITY_TYPE = 'users';
|
||||
private static readonly SIGNATURE_DOCUMENT_NAME = 'signature';
|
||||
|
||||
getUserDocuments(userId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`);
|
||||
}
|
||||
|
||||
uploadSignature(userId: number, file: File): Observable<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
formData.append('name', SponsorDDService.SIGNATURE_DOCUMENT_NAME);
|
||||
formData.append('description', 'Approver signature, stamped onto printed demand drafts');
|
||||
return this.http.post<any>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents`, formData);
|
||||
}
|
||||
|
||||
deleteSignature(userId: number, documentId: number): Observable<any> {
|
||||
return this.http.delete<any>(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
getSignatureBlob(userId: number, documentId: number): Observable<Blob> {
|
||||
return this.http.get(`/${SponsorDDService.SIGNATURE_ENTITY_TYPE}/${userId}/documents/${documentId}/attachment`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds this user's uploaded signature document, if any, from their full document list. */
|
||||
findSignatureDocument(userId: number): Observable<any> {
|
||||
return this.getUserDocuments(userId).pipe(
|
||||
map((docs) => (docs || []).find((d) => d.name === SponsorDDService.SIGNATURE_DOCUMENT_NAME) || null)
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetches this user's signature (if any) as a data URI ready to use in an <img> src or print HTML. */
|
||||
getSignatureDataUri(userId: number): Observable<string | null> {
|
||||
return this.findSignatureDocument(userId).pipe(
|
||||
switchMap((doc) => {
|
||||
if (!doc) {
|
||||
return of(null);
|
||||
}
|
||||
return this.getSignatureBlob(userId, doc.id).pipe(
|
||||
switchMap(
|
||||
(blob) =>
|
||||
new Observable<string>((subscriber) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
subscriber.next(reader.result as string);
|
||||
subscriber.complete();
|
||||
};
|
||||
reader.onerror = () => subscriber.error(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user