done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
@@ -14,6 +14,26 @@
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
</div>
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu.
Without it, a page listing every DD series gives no clue which member is waiting at the
counter or how much the draft has to be drawn for. -->
@if (dividendContext) {
<div class="dividend-banner">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} ·
{{ dividendContext.amount | number: '1.2-2' }}
@if (dividendContext.financialYear) {
· FY {{ dividendContext.financialYear }}
}
</span>
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span>
</div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
</div>
}
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- ID Column -->
@@ -80,8 +100,15 @@
<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
<!-- The dividend context travels on, so the leaflets page knows this is a dividend
draft and not an ordinary counter sale. -->
<button
mat-button
class="action-btn"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
@@ -8,7 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, Router, 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';
@@ -103,6 +103,34 @@ import { SponsorDDService } from '../sponsor-dd.service';
color: #b91c1c;
font-weight: 600;
}
.dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
}
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.dividend-banner__text strong {
font-size: 13.5px;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.dividend-banner button {
margin-left: auto;
}
`
]
})
@@ -110,6 +138,17 @@ export class DdInventoryListComponent implements OnInit {
private sponsorDDService = inject(SponsorDDService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private route = inject(ActivatedRoute);
private router = inject(Router);
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim
* rather than from the menu.
*
* Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the
* banner, the relabelled action and the forwarded query params all hang off this being set.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
displayedColumns: string[] = [
'id',
@@ -126,9 +165,51 @@ export class DdInventoryListComponent implements OnInit {
dataSource: any[] = [];
ngOnInit(): void {
this.readDividendContext();
this.loadInventories();
}
/**
* Picks up the dividend a claim sent us here to pay out.
*
* Read from the URL rather than handed over in a service so a refreshed or bookmarked page still
* knows which lock it is drawing against — losing it silently would issue an ordinary counter
* draft against the member instead of settling their dividend.
*/
private readDividendContext(): void {
const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']);
if (!lockId) {
return;
}
this.dividendContext = {
lockId,
amount: Number(params['dividendAmount']) || 0,
beneficiary: params['dividendBeneficiary'] ?? '',
financialYear: params['dividendFinancialYear'] ?? ''
};
}
/** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */
get dividendQueryParams(): Record<string, string | number> {
return this.dividendContext
? {
dividendLockId: this.dividendContext.lockId,
dividendAmount: this.dividendContext.amount,
dividendBeneficiary: this.dividendContext.beneficiary,
dividendFinancialYear: this.dividendContext.financialYear
}
: {};
}
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
}
loadInventories(): void {
this.sponsorDDService.getInventories().subscribe({
next: (data) => {
@@ -189,6 +189,12 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
if (element.paymentMethod === 'CHEQUE') {
return 'Cheque';
}
// Nothing is collected — the society already owes the member this money, so the draft is funded
// from Dividend Payable. Named rather than left as "-" so a checker can see at a glance that no
// purchaser will be debited when they approve it.
if (element.paymentMethod === 'DIVIDEND') {
return 'Dividend payout';
}
if (element.savingsAccountId) {
return 'Savings';
}
@@ -19,6 +19,20 @@
</div>
</div>
<!-- Says why the clerk is here when they arrived from a dividend claim, and which member is
waiting. Without it a registry of DD numbers gives no clue what the next click commits to. -->
<div class="dividend-banner" *ngIf="dividendContext">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }}
<ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container>
</span>
<span class="dividend-banner__hint">Pick an available leaflet below and press Issue.</span>
</div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</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" />
+232 -18
View File
@@ -8,7 +8,7 @@
import { Component, OnInit, AfterViewInit, ViewChild, inject } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http';
import { debounceTime } from 'rxjs/operators';
import { SponsorDDService } from '../sponsor-dd.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
@Component({
selector: 'mifosx-dd-list',
@@ -50,6 +51,31 @@ import { SponsorDDService } from '../sponsor-dd.service';
max-width: 1200px;
margin: 0 auto;
}
.dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
}
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.dividend-banner button {
margin-left: auto;
}
.header-row {
display: flex;
justify-content: space-between;
@@ -155,11 +181,18 @@ import { SponsorDDService } from '../sponsor-dd.service';
export class DdListComponent implements OnInit, AfterViewInit {
private sponsorDDService = inject(SponsorDDService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@ViewChild(MatPaginator) paginator!: MatPaginator;
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim.
* Null on an ordinary visit, which leaves every existing behaviour on this page untouched.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
inventoryId!: number;
inventoryDetails: any = null;
displayedColumns: string[] = [
@@ -194,6 +227,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
ngOnInit(): void {
this.inventoryId = +this.route.snapshot.params['inventoryId'];
this.readDividendContext();
this.dataSource.filterPredicate = (data) => {
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
const searchMatches =
@@ -207,6 +241,38 @@ export class DdListComponent implements OnInit, AfterViewInit {
this.loadLeaflets();
}
/**
* Picks up the dividend a claim sent the clerk here to pay out, and shows the leaflets they can
* actually use for it.
*
* The page normally opens on today's issued drafts, which is the right view for reconciling but
* exactly the wrong one here — a clerk sent to pick a blank leaflet would land on a list with
* none in it. Dividend mode opens on AVAILABLE instead.
*/
private readDividendContext(): void {
const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']);
if (!lockId) {
return;
}
this.dividendContext = {
lockId,
amount: Number(params['dividendAmount']) || 0,
beneficiary: params['dividendBeneficiary'] ?? '',
financialYear: params['dividendFinancialYear'] ?? ''
};
this.selectedStatus = 'AVAILABLE';
this.todayOnly = false;
}
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
}
@@ -276,12 +342,25 @@ export class DdListComponent implements OnInit, AfterViewInit {
openIssueDialog(leaflet: any): void {
const dialogRef = this.dialog.open(IssueDraftDialogComponent, {
width: '560px',
data: { leaflet, inventoryId: this.inventoryId },
// The dividend, when the clerk came here from a claim. Passing it through puts the dialog in
// dividend mode: beneficiary and amount arrive filled in, there is no payment to collect
// because the society already owes the money, and submitting files the claim rather than an
// ordinary issue request.
data: { leaflet, inventoryId: this.inventoryId, dividend: this.dividendContext },
disableClose: true
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
// A dividend claim is finished the moment it is filed — the draft is drawn on approval, and
// there is nothing further for the clerk to do on this page.
if (this.dividendContext) {
this.router.navigate([
'/clerk',
'dividend-dashboard'
]);
return;
}
this.loadLeaflets();
}
});
@@ -366,14 +445,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
],
providers: [DatePipe],
template: `
<h2 mat-dialog-title>Issue Demand Draft (DD No: {{ data.leaflet.ddNumber }})</h2>
<h2 mat-dialog-title>
{{ dividend ? 'Pay Dividend by Demand Draft' : 'Issue Demand Draft' }} (DD No:
{{ data.leaflet.ddNumber }})
</h2>
<mat-dialog-content>
<p class="hint-text">
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited, Sponsor
DD Payable GL credited) once the request is approved.
</p>
<!-- A dividend draft is not a counter sale: the society already owes the member the money, so
there is nothing to collect and the beneficiary and amount are fixed by the entitlement.
The clerk is left with only the branch and city to fill in. -->
<ng-container *ngIf="dividend; else counterSaleHint">
<div class="dividend-callout">
<strong>Settling a dividend</strong>
<span>
{{ dividend.beneficiary }} · {{ dividend.amount | number: '1.2-2' }}
<ng-container *ngIf="dividend.financialYear"> · FY {{ dividend.financialYear }}</ng-container>
</span>
<span class="dividend-callout__hint">
Funded from Dividend Payable — no charge, no GST, nothing collected from the member. The draft is drawn
once a manager approves the claim.
</span>
</div>
</ng-container>
<ng-template #counterSaleHint>
<p class="hint-text">
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited,
Sponsor DD Payable GL credited) once the request is approved.
</p>
</ng-template>
<form [formGroup]="issueForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
<mat-label>How is the customer paying?</mat-label>
<mat-select formControlName="paymentMethod">
<mat-option value="ACCOUNT_DEBIT">Debit from their Savings/Current Account</mat-option>
@@ -382,7 +482,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</mat-select>
</mat-form-field>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType">
@@ -410,7 +510,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</p>
</ng-container>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CHEQUE'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CHEQUE'">
<div class="row">
<mat-form-field appearance="outline" class="half-width">
<mat-label>Cheque Number</mat-label>
@@ -426,7 +526,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
</div>
</ng-container>
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CASH'">
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CASH'">
<p class="hint-text">
Walk-in purchaser paying cash — need not be an existing bank customer. Record their details for the DD
record; the Aadhaar number is masked before it is stored.
@@ -501,7 +601,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="issueForm.get('paymentMethod')?.value !== 'CASH'"
*ngIf="!dividend && issueForm.get('paymentMethod')?.value !== 'CASH'"
>
<mat-label>Client (Customer) ID</mat-label>
<input matInput type="number" formControlName="clientId" />
@@ -537,13 +637,14 @@ export class DdListComponent implements OnInit, AfterViewInit {
<mat-form-field appearance="outline" class="full-width">
<mat-label>DD Face Value (₹)</mat-label>
<input matInput type="number" formControlName="faceValue" />
<mat-hint *ngIf="issueForm.get('faceValue')?.disabled">
<mat-hint *ngIf="dividend">Locked — the member's unclaimed dividend for this lock.</mat-hint>
<mat-hint *ngIf="!dividend && issueForm.get('faceValue')?.disabled">
Locked — from the DD Charges &amp; GST breakdown entered at Transaction Entry.
</mat-hint>
<mat-error *ngIf="issueForm.get('faceValue')?.hasError('required')">Face Value is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
<mat-label>Charges / Commission (₹)</mat-label>
<input matInput type="number" formControlName="chargesAmount" />
<mat-hint *ngIf="issueForm.get('chargesAmount')?.disabled">
@@ -560,8 +661,8 @@ export class DdListComponent implements OnInit, AfterViewInit {
<ng-container
*ngIf="
issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
issueForm.get('paymentMethod')?.value === 'CHEQUE'
!dividend && (issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
issueForm.get('paymentMethod')?.value === 'CHEQUE')
"
>
<p class="hint-text" *ngIf="additionalLinesArray.length > 0">
@@ -638,16 +739,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
[disabled]="
issueForm.invalid ||
loading ||
(issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
(!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
"
(click)="onSubmit()"
>
Submit for Approval
{{ dividend ? 'Submit Claim Request' : 'Submit for Approval' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.dividend-callout {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
font-size: 13px;
color: #312e81;
}
.dividend-callout__hint {
margin-top: 4px;
color: #4338ca;
font-size: 12px;
line-height: 1.5;
}
.dialog-form {
display: flex;
flex-direction: column;
@@ -702,6 +822,7 @@ export class IssueDraftDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<IssueDraftDialogComponent>);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private claimRequestService = inject(DividendClaimRequestService);
protected data = inject(MAT_DIALOG_DATA);
issueForm!: FormGroup;
@@ -718,6 +839,53 @@ export class IssueDraftDialogComponent implements OnInit {
ddLimitAmount: number | null = null;
availableCashierDeposits: any[] = [];
/**
* The dividend this draft settles, when the dialog was opened from a dividend claim.
*
* A dividend draft is not a counter sale: the society already owes the member the money, so
* there is no purchaser to debit, no charge and no GST. The beneficiary and the amount are fixed
* by the entitlement itself, which leaves the clerk only the branch and city to fill in.
*/
get dividend(): { lockId: number; amount: number; beneficiary: string; financialYear: string } | null {
return this.data?.dividend ?? null;
}
/**
* Locks the form to what the dividend already decided and drops everything a counter sale needs.
*
* The payment-method controls are cleared of their validators rather than left required-and-empty
* — the form would otherwise never become valid and the clerk would be staring at a dead Submit
* button with no field to fix.
*/
private applyDividendMode(): void {
const dividend = this.dividend;
if (!dividend) {
return;
}
this.issueForm.patchValue({
beneficiaryName: dividend.beneficiary,
faceValue: dividend.amount,
chargesAmount: 0
});
// Fixed by the entitlement — a clerk cannot pay a different member, or a different amount, than
// the dividend the claim was raised against.
this.issueForm.get('beneficiaryName')?.disable();
this.issueForm.get('faceValue')?.disable();
for (const name of [
'clientId',
'accountNumber',
'paymentMethod',
'chargesAmount'
]) {
const control = this.issueForm.get(name);
control?.clearValidators();
control?.updateValueAndValidity();
}
}
ngOnInit(): void {
this.sponsorDDService.getGlMappings().subscribe({
next: (mappings) => {
@@ -787,6 +955,8 @@ export class IssueDraftDialogComponent implements OnInit {
additionalLines: this.fb.array([])
});
this.applyDividendMode();
this.forceUppercase([
'beneficiaryName',
'payableAtBranch',
@@ -964,9 +1134,53 @@ export class IssueDraftDialogComponent implements OnInit {
this.dialogRef.close(false);
}
/**
* Files the dividend claim rather than an ordinary issue request.
*
* The draft itself is drawn when a manager approves the claim — that approval already is the
* check on this money, so routing it through the DD maker-checker as well would put a member's
* own dividend behind two separate approvals for no added control.
*/
private submitDividendClaim(): void {
const dividend = this.dividend!;
const formVal = this.issueForm.getRawValue();
this.loading = true;
this.claimRequestService
.submit(dividend.lockId, {
ddApplyAmount: Number(formVal.faceValue),
ddBeneficiaryName: formVal.beneficiaryName,
ddPayableAtBranch: formVal.payableAtBranch,
ddPayableAtCity: formVal.payableAtCity,
// The leaflet the clerk actually opened this dialog on. The draft has to be drawn on the
// physical form in their hand, not on whichever one the server would pick next.
leafletId: this.data?.leaflet?.id
})
.subscribe({
next: (res: any) => {
const ddNumber = res?.ddNumber ? ` DD No ${res.ddNumber}.` : '';
this.snackBar.open(
`Dividend draft submitted for approval.${ddNumber} It now appears under DD Issuance Approvals.`,
'Close',
{ duration: 6000 }
);
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
}
});
}
onSubmit(): void {
if (this.issueForm.invalid) return;
if (this.dividend) {
this.submitDividendClaim();
return;
}
const formVal = this.issueForm.getRawValue();
if (formVal.paymentMethod === 'ACCOUNT_DEBIT' && this.accountLookupState !== 'found') {
this.snackBar.open(