feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
@@ -56,19 +56,30 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false;
declarationsLoaded = false;
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
/**
* Financial years that actually have locks, newest first — derived from the data rather than
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
* gone stops being offered. No maintenance as years roll over.
*/
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
/**
* A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
* loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
* in whose favour, and payable where.
*/
claimDdAmount: Record<number, number | null> = {};
claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
@@ -77,6 +88,13 @@ export class DividendDashboardComponent implements OnInit {
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
/**
* The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number
* after the row it came from has left the pending list — it is what goes on the counterfoil and
* into the member's hand, and there is nowhere else on this screen to find it.
*/
lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null;
expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {};
@@ -95,74 +113,45 @@ export class DividendDashboardComponent implements OnInit {
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
// Defaults to the whole unclaimed balance and the member's own name, which is what a member
// asks for almost every time; the clerk overrides only when the member wants otherwise.
this.claimDdAmount[lock.lockId] = Number(lock.amount);
this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
this.claimDdPayableAtBranch[lock.lockId] = '';
this.claimDdPayableAtCity[lock.lockId] = '';
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
if (ddAmount <= 0) {
this.claimError = 'Enter a DD amount greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
if (ddAmount > Number(lock.amount)) {
this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
if (!beneficiary) {
this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
if (!payableAtBranch || !payableAtCity) {
this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
ddApplyAmount: ddAmount,
ddBeneficiaryName: beneficiary,
ddPayableAtBranch: payableAtBranch,
ddPayableAtCity: payableAtCity
})
.subscribe({
next: () => {
@@ -193,8 +182,18 @@ export class DividendDashboardComponent implements OnInit {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: () => {
next: (res: any) => {
this.claimActioningId = null;
// The draft number is the one thing the clerk needs off this action — it is what they write
// on the counterfoil and hand to the member. Approving refreshes the list, so the row this
// came from disappears; without surfacing it here the number would be lost.
if (res?.ddNumber) {
this.lastIssuedDd = {
ddNumber: res.ddNumber,
clientName: req.client_name,
amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0
};
}
this.loadClaimRequests();
this.loadLocks();
this.loadSummary();
@@ -269,16 +268,49 @@ export class DividendDashboardComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.rebuildLockYears();
this.locksLoading = false;
this.locksLoaded = true;
});
}
/**
* Rebuilds the year chips from whatever locks came back, newest first.
*
* A year the current status filter has no locks for disappears from the chips, so the selected
* year is reset to ALL when that happens — otherwise the table would show nothing with no
* indication why.
*/
private rebuildLockYears(): void {
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
b.localeCompare(a)
);
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
this.lockYearFilter = 'ALL';
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
get visibleLocks(): any[] {
return this.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
get visibleLocksTotal(): number {
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
setLockYearFilter(year: string): void {
this.lockYearFilter = year;
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':