diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0dac0a1d2..4136ae258 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -62,6 +62,7 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module'; import { CfcfrmsModule } from './cfcfrms/cfcfrms.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { DeafModule } from './deaf/deaf.module'; +import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { GlReportsModule } from './gl-reports/gl-reports.module'; @@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) { CfcfrmsModule, BankCalendarModule, DeafModule, + SavingsDailyInterestModule, DicgcModule, FiuModule, GlReportsModule, diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 5097f9403..0c9ce396e 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -21,6 +21,8 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone import { CashHistoryComponent } from './cash-history/cash-history.component'; import { DayBookComponent } from './day-book/day-book.component'; import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component'; +import { VoucherViewComponent } from './voucher-view/voucher-view.component'; +import { VoucherReportComponent } from './voucher-report/voucher-report.component'; import { BranchDayComponent } from './branch-day/branch-day.component'; import { VaultPositionComponent } from './vault-position/vault-position.component'; import { AccountRegisterComponent } from './account-register/account-register.component'; @@ -221,6 +223,16 @@ const routes: Routes = [ component: VoucherExceptionsComponent, data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' } }, + { + path: 'voucher', + component: VoucherViewComponent, + data: { title: 'Voucher', breadcrumb: 'Voucher' } + }, + { + path: 'voucher-report', + component: VoucherReportComponent, + data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' } + }, { path: 'closing-report', component: ClosingCashReportComponent, diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 65e4cce0b..95aacd634 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -186,6 +186,35 @@ export class CashierService { return this.http.get('/vouchers/exceptions', { params }); } + /** + * The vouchers raised over a period, one row each, with what was debited and what credited. + * + * Who appears is decided by the server from the caller's own roles — a cashier sees only their + * own work whatever they ask for — so `username` is a filter for those allowed to use it, not a + * way to choose whose report this is. + */ + voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable { + let params = new HttpParams().set('from', from).set('to', to); + if (username) { + params = params.set('username', username); + } + if (voucherType) { + params = params.set('voucherType', voucherType); + } + return this.http.get('/vouchers/report', { params }); + } + + /** + * One voucher and the debit and credit sides it is made of — what a printed voucher shows. + * + * The voucher number travels as a query parameter, not a path segment: numbers are formatted + * CR/2708/0001 and the server rejects the encoded slashes in a path. + */ + voucherDetail(voucherNo: string): Observable { + const params = new HttpParams().set('voucherNo', voucherNo); + return this.http.get('/vouchers/detail', { params }); + } + /** Closes an exception. Manager / head cashier only, and a supporting document must exist. */ resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable { return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes }); diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html new file mode 100644 index 000000000..fc13e78c3 --- /dev/null +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -0,0 +1,183 @@ + + +
+
+
+
CASHIER · REPORT
+

Voucher Report

+

+ Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it. +

+
+
+ + + @if (report?.canSeeAllUsers) { + + } + + @if (report && report.count > 0) { + + } +
+
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (report && !loading) { +
+ {{ report.from | date: 'dd MMM yyyy' }} + @if (report.from !== report.to) { + — {{ report.to | date: 'dd MMM yyyy' }} + } + · {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }} + · ₹{{ report.totalAmount | number: '1.2-2' }} + @if (report.username) { + · {{ report.username }} + } + + @if (!report.canSeeAllUsers) { + Your own vouchers + } +
+ + @if (report.count === 0) { +
No vouchers were raised in this period.
+ } @else { +
+
+ + + + + + + + + + + @for (row of byType; track row.type) { + + + + + + } + +
TypeCountAmount
{{ row.type }}{{ row.count }}{{ row.total | number: '1.2-2' }}
+
+ + + @if (report.canSeeAllUsers) { +
+ + + + + + + + + + + @for (row of byUser; track row.username) { + + + + + + } + +
UserCountAmount
{{ row.username }}{{ row.count }}{{ row.total | number: '1.2-2' }}
+
+ } +
+ +
+ + + + + + + + + + + + + + + @for (row of report.vouchers; track row.voucherNo) { + + + + + + + + + + } + +
Voucher NoTypeDebitCreditAmountUserTime
+ + {{ row.voucherNo }} + + @if (row.verificationStatus === 'EXCEPTION') { + error_outline + } + {{ row.voucherType || '—' }} +
{{ row.debitParty || '—' }}
+ @if (row.debitAccount) { +
{{ row.debitAccount }}
+ } +
+
{{ row.creditParty || '—' }}
+ @if (row.creditAccount) { +
{{ row.creditAccount }}
+ } + + @if (row.extraLines > 0) { +
+{{ row.extraLines }} more on voucher
+ } +
{{ row.amount | number: '1.2-2' }}{{ row.username || '—' }}{{ row.createdAt | date: 'dd MMM, HH:mm' }}
+
+ } + } +
diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss new file mode 100644 index 000000000..00aeecb1c --- /dev/null +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -0,0 +1,80 @@ +/** + * 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/. + */ + +/* Page chrome, cards and tables come from cashier-shared.scss. Only the register itself is here. */ + +.vr-period { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + font-size: 13px; + color: #374151; +} + +.vr-scope-note { + padding: 2px 8px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 999px; + color: #1e40af; + font-size: 11px; +} + +/* The two summaries sit side by side where there is room and stack where there is not, so a + manager reads the totals before the rows on any screen. */ +.vr-summaries { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin-bottom: 14px; +} + +.vr-rows { + .vr-link { + color: #1d4ed8; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + + .vr-flag { + margin-left: 4px; + font-size: 15px; + width: 15px; + height: 15px; + color: #b91c1c; + vertical-align: text-bottom; + } + + .vr-party { + color: #111827; + } + + .vr-acct, + .vr-more { + margin-top: 2px; + font-size: 11px; + color: #6b7280; + } +} + +/* Printing a report should put the register on paper — the filters are how you reached it. */ +@media print { + .vr-no-print { + display: none !important; + } + + .vr-link { + color: inherit; + text-decoration: none; + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts new file mode 100644 index 000000000..e80bc59c6 --- /dev/null +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -0,0 +1,159 @@ +/** + * 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 } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +/** One voucher as the register shows it: the number, the two sides, and who raised it. */ +interface VoucherRow { + voucherNo: string; + voucherType: string | null; + amount: number | null; + createdAt: string | null; + userId: number | null; + username: string | null; + branchName: string | null; + verificationStatus: string | null; + debitParty: string | null; + debitAccount: string | null; + creditParty: string | null; + creditAccount: string | null; + /** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */ + extraLines: number; +} + +interface VoucherReport { + from: string; + to: string; + username: string | null; + /** Set by the server from the caller's roles; a cashier is never offered the user filter. */ + canSeeAllUsers: boolean; + vouchers: VoucherRow[]; + count: number; + totalAmount: number; +} + +/** + * The vouchers raised over a day or a month, one row each. + * + * A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to + * one user. That is decided by the server, not here — this screen only shows or hides the filter. + */ +@Component({ + selector: 'mifosx-voucher-report', + templateUrl: './voucher-report.component.html', + styleUrls: [ + '../cashier-shared.scss', + './voucher-report.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterLink, + MatIconModule + ] +}) +export class VoucherReportComponent implements OnInit { + private service = inject(CashierService); + + /** Day is the working default; a month is what you ask for at a close. */ + period: 'DAY' | 'MONTH' = 'DAY'; + /** Anchor date — the day itself, or any day inside the month wanted. */ + anchor = this.todayIso(); + usernameFilter = ''; + typeFilter = ''; + + report: VoucherReport | null = null; + loading = false; + error: string | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = null; + + const { from, to } = this.range(); + this.service + .voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined) + .pipe( + catchError((err: any) => { + this.error = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + 'The voucher report could not be loaded.'; + return of(null); + }) + ) + .subscribe((res: VoucherReport | null) => { + this.loading = false; + this.report = res; + }); + } + + /** The period the anchor date names: that day, or the whole calendar month around it. */ + private range(): { from: string; to: string } { + if (this.period === 'DAY') { + return { from: this.anchor, to: this.anchor }; + } + const [ + year, + month + ] = this.anchor.split('-').map(Number); + // Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is + // ended correctly without a table of month lengths. + const lastDay = new Date(year, month, 0).getDate(); + const mm = String(month).padStart(2, '0'); + return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` }; + } + + private todayIso(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + } + + /** Totals per user, for the summary a manager reads before the rows. */ + get byUser(): { username: string; count: number; total: number }[] { + const rows = this.report?.vouchers ?? []; + const tally = new Map(); + for (const row of rows) { + const key = row.username ?? '—'; + const entry = tally.get(key) ?? { username: key, count: 0, total: 0 }; + entry.count += 1; + entry.total += row.amount ?? 0; + tally.set(key, entry); + } + return [...tally.values()].sort((a, b) => b.total - a.total); + } + + /** Totals per voucher type — deposits against withdrawals against journals. */ + get byType(): { type: string; count: number; total: number }[] { + const rows = this.report?.vouchers ?? []; + const tally = new Map(); + for (const row of rows) { + const key = row.voucherType ?? '—'; + const entry = tally.get(key) ?? { type: key, count: 0, total: 0 }; + entry.count += 1; + entry.total += row.amount ?? 0; + tally.set(key, entry); + } + return [...tally.values()].sort((a, b) => b.total - a.total); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/cashier/voucher-view/voucher-view.component.html b/src/app/cashier/voucher-view/voucher-view.component.html new file mode 100644 index 000000000..64c2f7d9c --- /dev/null +++ b/src/app/cashier/voucher-view/voucher-view.component.html @@ -0,0 +1,144 @@ + + +
+
+
+
CASHIER · VOUCHER
+

Voucher

+

+ Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited, + and for how much. +

+
+
+ + + @if (voucher) { + + } +
+
+ + @if (error) { +
{{ error }}
+ } + + @if (loading) { +
Loading…
+ } + + @if (voucher && !loading) { +
+
+
+
{{ voucher.voucherNo }}
+
+ {{ voucher.voucherType || '—' }} + @if (voucher.branchName) { + · {{ voucher.branchName }} + } +
+
+
+
₹{{ voucher.amount | number: '1.2-2' }}
+
+ @if (voucher.createdAt) { + {{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }} + } + @if (voucher.createdBy) { + · {{ voucher.createdBy }} + } +
+
+
+ + @if (voucher.verificationStatus) { +
+ {{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }} + {{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }} +
+ } + + + @if (hasNoLines) { +
+ No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept. +
+ } @else { + + + + + + + + + + + @for (line of debitLines; track line.lineNo) { + + + + + + + } + @for (line of creditLines; track line.lineNo) { + + + + + + + } + + + + + + + + +
ParticularsAccountDebitCredit
+
{{ line.partyName || '—' }}
+ @if (line.description) { +
{{ line.description }}
+ } +
{{ line.accountNo || '—' }}{{ line.amount | number: '1.2-2' }}—
+
{{ line.partyName || '—' }}
+ @if (line.description) { +
{{ line.description }}
+ } +
{{ line.accountNo || '—' }}—{{ line.amount | number: '1.2-2' }}
Total{{ voucher.totalDebit | number: '1.2-2' }}{{ voucher.totalCredit | number: '1.2-2' }}
+ + + @if (!voucher.balanced) { +
+ This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it. +
+ } + } +
+ } +
diff --git a/src/app/cashier/voucher-view/voucher-view.component.scss b/src/app/cashier/voucher-view/voucher-view.component.scss new file mode 100644 index 000000000..1cd92f93c --- /dev/null +++ b/src/app/cashier/voucher-view/voucher-view.component.scss @@ -0,0 +1,109 @@ +/** + * 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/. + */ + +/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */ + +.vv-slip { + max-width: 860px; +} + +.vv-slip-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid #e5e7eb; +} + +.vv-slip-head-right { + text-align: right; +} + +.vv-no { + font-size: 18px; + font-weight: 600; + letter-spacing: 0.4px; + color: #111827; +} + +.vv-amount { + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.vv-meta { + margin-top: 2px; + font-size: 12px; + color: #6b7280; +} + +.vv-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 14px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 8px; + color: #065f46; + font-size: 12px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + /* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */ + &.vv-status--exception { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } +} + +.vv-lines { + /* Credits sit under their debits the way they do on a written slip, so the indent carries the + relationship that the DR/CR columns alone would leave to the reader to work out. */ + .vv-indent { + padding-left: 28px; + } + + .vv-party { + color: #111827; + } + + .vv-desc { + margin-top: 2px; + font-size: 11px; + color: #6b7280; + } + + tfoot td { + font-weight: 600; + border-top: 2px solid #e5e7eb; + } +} + +/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are + how you reached it, not part of it. */ +@media print { + .vv-no-print { + display: none !important; + } + + .vv-slip { + max-width: none; + border: none; + box-shadow: none; + } +} diff --git a/src/app/cashier/voucher-view/voucher-view.component.ts b/src/app/cashier/voucher-view/voucher-view.component.ts new file mode 100644 index 000000000..53cb33701 --- /dev/null +++ b/src/app/cashier/voucher-view/voucher-view.component.ts @@ -0,0 +1,143 @@ +/** + * 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, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; +import { catchError, of } from 'rxjs'; +import { CashierService } from '../cashier.service'; + +/** One side of a voucher: a debit or a credit against a single account. */ +interface VoucherLine { + lineNo: number; + entryType: 'DR' | 'CR'; + glAccountId: number | null; + savingsAccountId: number | null; + loanAccountId: number | null; + partyName: string | null; + accountNo: string | null; + amount: number; + description: string | null; +} + +interface Voucher { + voucherNo: string; + voucherType: string | null; + referenceType: string | null; + referenceId: number | null; + amount: number | null; + branchOfficeId: number | null; + branchName: string | null; + createdBy: string | null; + createdAt: string | null; + verificationStatus: string | null; + lines: VoucherLine[]; + totalDebit: number; + totalCredit: number; + balanced: boolean; +} + +/** + * One voucher, as a slip: which accounts were debited, which credited, and for how much. + * + * Looked up by voucher number rather than listed, because that is how a voucher is reached — from a + * paper slip in someone's hand, or from a number quoted on a statement. + */ +@Component({ + selector: 'mifosx-voucher-view', + templateUrl: './voucher-view.component.html', + styleUrls: [ + '../cashier-shared.scss', + './voucher-view.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule + ] +}) +export class VoucherViewComponent { + private service = inject(CashierService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + voucherNo = ''; + voucher: Voucher | null = null; + loading = false; + /** Shown in place of the slip — a bad number and a server error read differently to the user. */ + error: string | null = null; + + constructor() { + // A voucher number in the URL means someone arrived from a link or a statement rather than by + // typing, so the slip is fetched straight away instead of showing them an empty search box. + const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo'); + if (fromUrl) { + this.voucherNo = fromUrl; + this.search(); + } + } + + search(): void { + const trimmed = (this.voucherNo || '').trim(); + if (!trimmed) { + this.error = 'Enter a voucher number.'; + this.voucher = null; + return; + } + + this.loading = true; + this.error = null; + + // Kept in the URL so the slip can be linked to and survives a refresh. + this.router.navigate([], { + relativeTo: this.route, + queryParams: { voucherNo: trimmed }, + replaceUrl: true + }); + + this.service + .voucherDetail(trimmed) + .pipe( + catchError((err: any) => { + this.error = + err?.error?.errors?.[0]?.defaultUserMessage ?? + err?.error?.developerMessage ?? + `Voucher ${trimmed} could not be loaded.`; + return of(null); + }) + ) + .subscribe((res: Voucher | null) => { + this.loading = false; + this.voucher = res; + }); + } + + /** Debits first, then credits — the order a slip is read in. */ + get debitLines(): VoucherLine[] { + return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR'); + } + + get creditLines(): VoucherLine[] { + return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR'); + } + + /** + * A voucher raised before its sides were recorded. The voucher is real and its number valid — only + * the posting behind it was never captured — so this reads as an older record, not a broken one. + */ + get hasNoLines(): boolean { + return !!this.voucher && this.voucher.lines.length === 0; + } + + print(): void { + window.print(); + } +} diff --git a/src/app/clerk/dividend-claim-request.service.ts b/src/app/clerk/dividend-claim-request.service.ts index ada9c126d..9edfb2740 100644 --- a/src/app/clerk/dividend-claim-request.service.ts +++ b/src/app/clerk/dividend-claim-request.service.ts @@ -34,6 +34,12 @@ export class DividendClaimRequestService { ddBeneficiaryName: string; ddPayableAtBranch: string; ddPayableAtCity: string; + /** + * The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical + * form — without it the server takes the next available one, and the number the clerk wrote + * on the counterfoil would not be the number in the books. + */ + leafletId?: number; } ): Observable { return this.http.post('/clerk/dividend-claim-requests', { @@ -41,7 +47,8 @@ export class DividendClaimRequestService { ddApplyAmount: String(claim.ddApplyAmount), ddBeneficiaryName: claim.ddBeneficiaryName, ddPayableAtBranch: claim.ddPayableAtBranch, - ddPayableAtCity: claim.ddPayableAtCity + ddPayableAtCity: claim.ddPayableAtCity, + leafletId: claim.leafletId ?? null }); } diff --git a/src/app/clerk/dividend-dashboard.service.ts b/src/app/clerk/dividend-dashboard.service.ts index 9493f5632..dbd093ce5 100644 --- a/src/app/clerk/dividend-dashboard.service.ts +++ b/src/app/clerk/dividend-dashboard.service.ts @@ -39,4 +39,5 @@ export class DividendDashboardService { const params = status && status !== 'ALL' ? { status } : {}; return this.http.get('/clerk/dividend-dashboard/locks', { params }); } + } diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html index 4a0ccd746..71c82c206 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html @@ -46,14 +46,69 @@ } -
+ +
+ + + +
+ +
-
- +
+ + + {{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{ + declarations.length === 1 ? '' : 's' + }} + +
@@ -75,8 +130,12 @@ + } @else if (visibleDeclarations.length === 0) { + + + } @else { - @for (d of declarations; track d.declarationId) { + @for (d of visibleDeclarations; track d.declarationId) { @@ -95,12 +154,27 @@
No dividends have been declared yet.
No declaration matches “{{ declarationSearch }}”.
{{ d.productName }} {{ d.periodStartDate }} → {{ d.periodEndDate }}
-
+
-
- +
+ + + {{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }} + +
@@ -122,10 +196,19 @@ + } @else if (visibleOverdueRows.length === 0) { + + + } @else { - @for (row of rows; track row.lockId) { + @for (row of visibleOverdueRows; track row.lockId) { - + @@ -181,7 +264,7 @@
No members with a locked dividend and an overdue EMI right now.
No member matches “{{ overdueSearch }}”.
{{ row.clientName }} + {{ row.clientName }} + @if (row.shareAccountNo) { + + } + {{ row.lockedAmount | number: '1.2-2' }} {{ row.lockedDate }} {{ row.releaseDueDate }}
-
+
@@ -196,9 +279,21 @@ }
- + +
- @if (l.status === 'UNCLAIMED') { - } - @if (expandedClaimLockId === l.lockId) { - - -
-
- Unclaimed amount: {{ l.amount | number: '1.2-2' }} - @if (l.financialYear) { - (FY {{ l.financialYear }} dividend) - } - — issue as a Demand Draft -
- - - - - -
- Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} / - {{ l.amount | number: '1.2-2' }} -
- @if (claimError) { -
{{ claimError }}
- } - -
- - - } - } - } - - -
- -
- - - - @if (lastIssuedDd) { -
-
- Demand Draft {{ lastIssuedDd.ddNumber }} issued to {{ lastIssuedDd.clientName }} for - {{ lastIssuedDd.amount | number: '1.2-2' }} -
- -
- } -
- -
- - - - - - - - - - - - - - - @if (claimRequestsLoading) { - - - - } @else if (claimRequestsLoaded && claimRequests.length === 0) { - - - - } @else { - @for (req of claimRequests; track req.id) { - - - - - - - - - - @if (expandedRejectClaimId === req.id) { - - - - } } } diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss index b3c1cb03d..e59af736e 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss @@ -244,3 +244,160 @@ } } } + +/* One toolbar per table: search on the left where the eye lands first, the row count next to it so + a narrowed table says how much it is hiding, and Refresh pushed to the far right. */ +.dd-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + + &__count { + margin-left: auto; + font-size: 12.5px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.dd-search { + position: relative; + flex: 1 1 260px; + max-width: 360px; + + &__input { + width: 100%; + padding: 7px 30px 7px 11px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + color: #0f172a; + background: #fff; + + &::placeholder { + color: #94a3b8; + } + + &:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + + /* The browser's own clear affordance would sit under ours. */ + &::-webkit-search-cancel-button { + appearance: none; + } + } + + /* Only rendered while there is something to clear, so it never reads as a dead control. */ + &__clear { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: #64748b; + font-size: 17px; + line-height: 1; + cursor: pointer; + + &:hover { + background: #e2e8f0; + color: #0f172a; + } + } +} + +/* The share account number under the member's name — the thing a clerk matches against the + passbook in front of them, so it has to be readable but must not outweigh the name. */ +.dd-account-no { + margin-top: 2px; + font-size: 11.5px; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +/* Horizontal tab strip: one list on screen at a time, chosen here. + Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions + a clerk has learned do not move. */ +.dd-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; + padding-bottom: 2px; + border-bottom: 1px solid #e2e8f0; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.dd-tab { + display: inline-flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; + padding: 9px 14px; + border: 0; + /* The active marker is this underline, so every tab reserves the space for it — otherwise the + strip would shift by 2px each time the selection moved. */ + border-bottom: 2px solid transparent; + background: transparent; + font-size: 12.5px; + font-weight: 600; + color: #64748b; + white-space: nowrap; + cursor: pointer; + + &:hover { + color: #1e40af; + } + + &--active { + color: #1e40af; + border-bottom-color: #2563eb; + } +} + +.dd-tab__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + border-radius: 9999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + + /* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding — + coloured so it reads as something to open rather than a neutral tally. */ + &--alert { + background: #fef3c7; + color: #92400e; + } +} + +/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it + against the counterfoil can compare digit by digit. */ +.dd-dd-number { + font-size: 12.5px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts index c2874bfd7..b486325d0 100644 --- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts @@ -9,17 +9,21 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { DividendDashboardService } from '../dividend-dashboard.service'; -import { DividendClaimRequestService } from '../dividend-claim-request.service'; /** - * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year - * lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a - * proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to - * claim their dividend directly (split across loan, savings, or cash). + * Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock + * history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a + * proactive "Apply to Loan" action). + * + * Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a + * Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every + * other draft, so there is no separate claim queue here — the lock history shows the DD number once + * it has been drawn. */ @Component({ selector: 'mifosx-dividend-dashboard', @@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service'; }) export class DividendDashboardComponent implements OnInit { private service = inject(DividendDashboardService); - private claimRequestService = inject(DividendClaimRequestService); + private router = inject(Router); + + /** + * Which list the clerk is looking at. + * + * The three lists answer unrelated questions — what has been declared, who is blocked by an + * overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one + * page they made the screen long enough that the section a clerk came for was usually below the + * fold, so they are tabbed instead. + * + * Every list is still loaded on init rather than on first view: the counts in the tab strip have + * to be right before a tab is opened, or the strip would be advertising numbers it does not know. + */ + activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations'; rows: any[] = []; loading = false; @@ -56,6 +73,21 @@ export class DividendDashboardComponent implements OnInit { declarationsLoading = false; declarationsLoaded = false; + /** + * Free-text search, one box per table. + * + * Kept separate rather than shared because the three tables answer different questions and a + * clerk is usually in one of them: narrowing the lock history to a member should not blank out + * the declarations table above it, which has nothing to do with that member. + * + * Filtering is done here rather than server-side because every row is already loaded — these are + * a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower + * than the filter itself and would blank the table while it ran. + */ + declarationSearch = ''; + overdueSearch = ''; + lockSearch = ''; + /** Every lock the status filter returned, before the year filter narrows it. */ locks: any[] = []; locksLoading = false; @@ -70,164 +102,42 @@ export class DividendDashboardComponent implements OnInit { lockYears: string[] = []; lockYearFilter = 'ALL'; - expandedClaimLockId: number | null = null; - /** - * 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 = {}; - claimDdBeneficiary: Record = {}; - claimDdPayableAtBranch: Record = {}; - claimDdPayableAtCity: Record = {}; - claimSubmitting = false; - claimError: string | null = null; - - claimRequests: any[] = []; - claimRequestsLoading = false; - claimRequestsLoaded = false; - claimActioningId: number | null = null; - claimActionError: Record = {}; - - /** - * 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 = {}; ngOnInit(): void { this.loadSummary(); this.loadDeclarations(); this.load(); this.loadLocks(); - this.loadClaimRequests(); } - toggleClaim(lock: any): void { - if (this.expandedClaimLockId === lock.lockId) { - this.expandedClaimLockId = null; - return; - } - this.expandedClaimLockId = lock.lockId; - this.claimError = null; - // 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 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 (ddAmount > Number(lock.amount)) { - this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`; - return; - } - if (!beneficiary) { - this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.'; - return; - } - 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, { - ddApplyAmount: ddAmount, - ddBeneficiaryName: beneficiary, - ddPayableAtBranch: payableAtBranch, - ddPayableAtCity: payableAtCity - }) - .subscribe({ - next: () => { - this.claimSubmitting = false; - this.expandedClaimLockId = null; - this.loadClaimRequests(); - }, - error: (err: any) => { - this.claimSubmitting = false; - this.claimError = err?.error?.message || 'Failed to submit claim request.'; + /** + * Takes the clerk to the DD inventory to draw the draft this dividend is paid out as. + * + * A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue + * one — and the place a draft is issued is the leaflet inventory, where the clerk can see which + * numbered leaflet they are about to commit. Gathering the draft's details on this dashboard + * instead meant filling a second, thinner version of the DD form and never seeing the DD number + * until after approval. + * + * The dividend travels in the query string rather than in a service, so a reloaded or + * bookmarked page still knows which lock it is drawing against instead of silently issuing an + * ordinary counter draft. + */ + claimAsDemandDraft(lock: any): void { + this.router.navigate( + [ + '/sponsor-dd', + 'inventory' + ], + { + queryParams: { + dividendLockId: lock.lockId, + dividendAmount: Number(lock.amount), + dividendBeneficiary: lock.clientName ?? '', + dividendFinancialYear: lock.financialYear ?? '' } - }); - } - - loadClaimRequests(): void { - this.claimRequestsLoading = true; - this.claimRequestService - .list('PENDING') - .pipe(catchError(() => of([]))) - .subscribe((res: any[]) => { - this.claimRequests = res ?? []; - this.claimRequestsLoading = false; - this.claimRequestsLoaded = true; - }); - } - - approveClaim(req: any): void { - this.claimActioningId = req.id; - delete this.claimActionError[req.id]; - this.claimRequestService.approve(req.id).subscribe({ - 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(); - }, - error: (err: any) => { - this.claimActioningId = null; - this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.'; } - }); - } - - toggleRejectClaim(req: any): void { - this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id; - } - - rejectClaim(req: any): void { - const reason = this.rejectReason[req.id]; - if (!reason || !reason.trim()) { - this.claimActionError[req.id] = 'Rejection reason is required.'; - return; - } - this.claimActioningId = req.id; - delete this.claimActionError[req.id]; - this.claimRequestService.reject(req.id, reason).subscribe({ - next: () => { - this.claimActioningId = null; - this.expandedRejectClaimId = null; - this.loadClaimRequests(); - }, - error: (err: any) => { - this.claimActioningId = null; - this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.'; - } - }); + ); } loadSummary(): void { @@ -253,6 +163,33 @@ export class DividendDashboardComponent implements OnInit { }); } + /** + * Whether any of a row's searchable fields contains the term. + * + * Substring rather than prefix matching, because a clerk reading an account number off a passbook + * often types the distinctive tail of it rather than the branch prefix every account shares. Null + * fields are skipped — a lock with no share account still has to be findable by member name. + */ + private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean { + const needle = term.trim().toLowerCase(); + if (!needle) { + return true; + } + return fields.some((field) => field != null && String(field).toLowerCase().includes(needle)); + } + + /** Declarations the table renders. Product-level rows, so there is no member to search on. */ + get visibleDeclarations(): any[] { + return this.declarations.filter((d) => + this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status) + ); + } + + /** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */ + get visibleOverdueRows(): any[] { + return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo)); + } + declarationStatusLabel(status: string): string { return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved'; } @@ -290,11 +227,16 @@ export class DividendDashboardComponent implements OnInit { } } - /** Locks the table actually renders — the status filter is applied server-side, the year here. */ + /** + * Locks the table actually renders — the status filter is applied server-side, the year and the + * search here. All three narrow together, so a clerk can hold a year and still search within it. + */ get visibleLocks(): any[] { - return this.lockYearFilter === 'ALL' - ? this.locks - : this.locks.filter((l) => l.financialYear === this.lockYearFilter); + return this.locks.filter( + (l) => + (this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) && + this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo) + ); } /** Total of what is on screen, so a year's worth of dividend can be read off directly. */ diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 95f7c3741..29be1c12a 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -187,6 +187,33 @@ Exceptions + + + + + + + Voucher + + + + + + + + + + + Voucher Report + ESC Show Header } @else { @@ -262,6 +289,20 @@ > Exceptions + + Voucher + + + Voucher Report + Voucher Exceptions + + @@ -1101,6 +1148,12 @@ + + @@ -1144,6 +1197,9 @@
Reports
+ @@ -1700,7 +1756,7 @@ -
+ +
+
Registers & Reports
+ + +
+
diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts index 2f9a34202..c0909eb59 100644 --- a/src/app/letter-mgmt/letter-mgmt-routing.module.ts +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -19,6 +19,9 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component' import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { NocCertificateComponent } from './certificates/noc/noc.component'; import { DatabaseExportComponent } from './database-export/database-export.component'; +import { ShareMemberComponent } from './share-member/share-member.component'; +import { MembershipRegisterComponent } from './membership-register/membership-register.component'; +import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component'; const routes: Routes = [ Route.withShell([ @@ -37,6 +40,21 @@ const routes: Routes = [ component: ShareCertificateComponent, data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' } }, + { + path: 'letter-mgmt/share-member', + component: ShareMemberComponent, + data: { title: 'Share Members', breadcrumb: 'Share Members' } + }, + { + path: 'letter-mgmt/membership-register', + component: MembershipRegisterComponent, + data: { title: 'Membership Register', breadcrumb: 'Membership Register' } + }, + { + path: 'letter-mgmt/share-allotment-report', + component: ShareAllotmentReportComponent, + data: { title: 'Share Allotment Report', breadcrumb: 'Share Allotment Report' } + }, { path: 'letter-mgmt/balance-certificate', component: BalanceCertificateComponent, diff --git a/src/app/letter-mgmt/letter-mgmt.module.ts b/src/app/letter-mgmt/letter-mgmt.module.ts index 20a063020..cab048ac0 100644 --- a/src/app/letter-mgmt/letter-mgmt.module.ts +++ b/src/app/letter-mgmt/letter-mgmt.module.ts @@ -24,6 +24,9 @@ import { HypothecationCertificateComponent } from './certificates/hypothecation/ import { NocCertificateComponent } from './certificates/noc/noc.component'; import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component'; import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component'; +import { ShareMemberComponent } from './share-member/share-member.component'; +import { MembershipRegisterComponent } from './membership-register/membership-register.component'; +import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component'; @NgModule({ imports: [ @@ -42,7 +45,10 @@ import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.compon HypothecationCertificateComponent, NocCertificateComponent, CertificatePreviewDialogComponent, - DscSignDialogComponent + DscSignDialogComponent, + ShareMemberComponent, + MembershipRegisterComponent, + ShareAllotmentReportComponent ] }) export class LetterMgmtModule {} diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.html b/src/app/letter-mgmt/membership-register/membership-register.component.html new file mode 100644 index 000000000..4f566df96 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.html @@ -0,0 +1,198 @@ + + +
+
+
+ +
+

Membership Register

+
+ Statutory register of members — one line per member, in order of admission. +
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ filteredRows.length | number }}
+
Members on Register
+
+
+
+ +
+
{{ activeMembers() | number }}
+
Holding Shares
+
+
+
+ +
+
{{ totalShares() | number }}
+
Total Shares Held
+
+
+
+ +
+
₹{{ totalValue() | number: '1.0-0' }}
+
Share Capital
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading membership register…
+ } @else if (loadError) { +
+ Could not load the membership register. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No member matches these filters. + } @else { + No members on the register yet. + } +
+ } @else { +
+
ClientDD AmountBeneficiaryPayable AtUnclaimed BalanceRequested By
Loading…
No dividend claim requests pending review.
{{ req.client_name }}{{ req.dd_apply_amount | number: '1.2-2' }}{{ req.dd_beneficiary_name || '—' }} - @if (req.dd_payable_at_branch || req.dd_payable_at_city) { - {{ req.dd_payable_at_branch }} - @if (req.dd_payable_at_branch && req.dd_payable_at_city) { - , - } - {{ req.dd_payable_at_city }} - } @else { - — - } - {{ req.locked_amount | number: '1.2-2' }}{{ req.clerk_name }} - - - @if (claimActionError[req.id]) { -
{{ claimActionError[req.id] }}
- } -
-
- - -
-
+ + + + + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + + + + + } + + + + + + + + + +
S.NoMember's NameMembership NoAddressDate of AdmissionNo. of Shares HeldShare ValueBranchMobile
{{ row.serial }} + {{ row.memberName }} + @if (row.accountCount > 1) { + + {{ row.accountCount }} a/c + + } + {{ row.membershipNo }}{{ row.address }}{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }} + {{ row.sharesHeld | number }} + @if (row.pendingShares) { + + +{{ row.pendingShares | number }} + + } + ₹{{ row.shareValue | number: '1.0-0' }}{{ row.branch }}{{ row.mobileNo }} + +
Total — {{ filteredRows.length | number }} members{{ totalShares() | number }}₹{{ totalValue() | number: '1.0-0' }}
+
+ } +
+ +
+ One line per member, not per share account — a member holding several share accounts appears once with their + holdings summed. Serial numbers follow date of admission. A "+n" beside the holding is shares applied for but not + yet approved. +
+
diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.scss b/src/app/letter-mgmt/membership-register/membership-register.component.scss new file mode 100644 index 000000000..139a5b0f7 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.scss @@ -0,0 +1,116 @@ +/** + * 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/. + */ + +.mr-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + &:hover .mr-chevron, + &:focus-visible .mr-chevron { + opacity: 1; + } +} + +.mr-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +/* The serial is the register's own numbering, so it reads as a label rather than a figure. */ +.mr-serial { + font-weight: 700; + color: var(--cert-g500); + font-variant-numeric: tabular-nums; +} + +.mr-address { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Shares still awaiting approval sit beside the holding rather than inside it — they are + not yet share capital, and adding them to the figure would overstate the register. */ +.mr-pending { + margin-left: 6px; + font-size: 11px; + font-weight: 700; + color: var(--cert-amber); +} + +.mr-pill--multi { + margin-left: 8px; + background: var(--cert-blue-l); + color: var(--cert-blue-d); + font-size: 10px; + padding: 2px 7px; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot .mr-total > td { + background: var(--cert-g50); + font-weight: 800; + color: var(--cert-g900); + border-top: 2px solid var(--cert-g200); + padding: 12px; + white-space: nowrap; + } +} + +.mr-retry { + margin-left: 10px; +} + +/* A register is a document that gets signed and filed, so printing drops the chrome. */ +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .mr-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .mr-address { + max-width: none; + white-space: normal; + } + + .cert-table { + font-size: 10px; + } + + /* Repeat the column headings on every printed page of a long register. */ + .cert-table thead { + display: table-header-group; + } + + .mr-row { + break-inside: avoid; + } +} diff --git a/src/app/letter-mgmt/membership-register/membership-register.component.ts b/src/app/letter-mgmt/membership-register/membership-register.component.ts new file mode 100644 index 000000000..8e252b3f3 --- /dev/null +++ b/src/app/letter-mgmt/membership-register/membership-register.component.ts @@ -0,0 +1,349 @@ +/** + * 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 } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** + * One line of the statutory Membership Register. + * + * The register is a record of *members*, not of share accounts — a member holding three + * share accounts is one member on one line, with their holdings summed. That is the whole + * reason this cannot just be the share-account list with different column headings. + */ +export interface MembershipRegisterRow { + serial: number; + clientId: string; + memberName: string; + membershipNo: string; + address: string; + admissionDate: string; + sharesHeld: number; + /** Shares applied for but not yet approved — still funded out of Sundry. */ + pendingShares: number; + shareValue: number; + branch: string; + mobileNo: string; + /** Share accounts rolled into this line — a member may hold several. */ + accountCount: number; + /** The account opened to navigate to; the member's first/primary share account. */ + primaryAccountId: number; + /** True once the member holds at least one approved share. */ + isActive: boolean; +} + +@Component({ + selector: 'mifosx-membership-register', + templateUrl: './membership-register.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './membership-register.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class MembershipRegisterComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: MembershipRegisterRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + statusFilter = ''; + fromDate = ''; + toDate = ''; + + private clientDataCache = new Map>(); + private clientAddressCache = new Map>(); + + ngOnInit(): void { + this.loadRegister(); + } + + loadRegister(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + this.clientAddressCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as any[]); + } + return forkJoin(items.map((item) => this.loadAccount(item))).pipe( + map((accounts) => accounts.filter((a) => !!a)) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as any[]); + }) + ) + .subscribe((accounts) => { + this.rows = this.collapseToMembers(accounts); + this.loading = false; + }); + } + + private loadAccount(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null), + address: clientId ? this.getClientAddressCached(clientId) : of([]) + }).pipe(catchError(() => of(null))); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + private getClientAddressCached(clientId: string): Observable { + if (!this.clientAddressCache.has(clientId)) { + this.clientAddressCache.set( + clientId, + this.clientsService.getClientAddressData(clientId, true).pipe( + catchError(() => of([])), + shareReplay(1) + ) + ); + } + return this.clientAddressCache.get(clientId)!; + } + + /** + * Collapses share accounts into one line per member, then numbers the lines by date of + * admission — a register's serial number is the order members joined, so it has to be + * assigned after sorting, not carried over from the account list. + */ + private collapseToMembers(accounts: any[]): MembershipRegisterRow[] { + const byMember = new Map(); + + for (const entry of accounts) { + const { sa, client, address } = entry; + if (!sa) { + continue; + } + const clientId = String(sa.clientId ?? client?.id ?? ''); + if (!clientId) { + continue; + } + + const approved = sa.summary?.totalApprovedShares || 0; + const pending = sa.summary?.totalPendingForApprovalShares || 0; + const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0; + + const existing = byMember.get(clientId); + if (existing) { + existing.sharesHeld += approved; + existing.pendingShares += pending; + existing.shareValue += approved * unitPrice; + existing.accountCount += 1; + existing.isActive = existing.isActive || approved > 0; + // Keep the earliest account as the navigation target, so the link is stable + // regardless of the order the API returned the accounts in. + existing.primaryAccountId = Math.min(existing.primaryAccountId, sa.id); + continue; + } + + byMember.set(clientId, { + serial: 0, + clientId, + memberName: sa.clientName || client?.displayName || '—', + membershipNo: client?.accountNo || sa.clientAccountNo || '—', + address: this.firstAddress(address), + admissionDate: + this.toIsoDate(client?.activationDate) || + this.toIsoDate(client?.timeline?.activatedOnDate) || + this.toIsoDate(sa.timeline?.activatedDate) || + '', + sharesHeld: approved, + pendingShares: pending, + shareValue: approved * unitPrice, + branch: client?.officeName || sa.officeName || '—', + mobileNo: client?.mobileNo || '—', + accountCount: 1, + primaryAccountId: sa.id, + isActive: approved > 0 + }); + } + + // Oldest admission first: member no. 1 is the society's first member. + const ordered = Array.from(byMember.values()).sort((a, b) => + (a.admissionDate || '').localeCompare(b.admissionDate || '') + ); + ordered.forEach((row, i) => (row.serial = i + 1)); + return ordered; + } + + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + private firstAddress(address: any): string { + const list = Array.isArray(address) ? address : address?.pageItems || []; + if (!list.length) { + return '—'; + } + const a = list[0]; + const parts = [ + a.addressLine1, + a.addressLine2, + a.townVillage, + a.city, + a.stateName, + a.countryName, + a.postalCode + ] + .filter((p: any) => !!p && String(p).trim()) + .join(', '); + return parts || '—'; + } + + // ── Derived list ── + get filteredRows(): MembershipRegisterRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.statusFilter === 'active' && !r.isActive) return false; + if (this.statusFilter === 'pending' && r.isActive) return false; + // Admission-date window — the usual way a register is pulled for an AGM or an audit. + if (this.fromDate && (!r.admissionDate || r.admissionDate < this.fromDate)) return false; + if (this.toDate && (!r.admissionDate || r.admissionDate > this.toDate)) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.address.toLowerCase().includes(q) || + r.mobileNo.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + totalShares(): number { + return this.filteredRows.reduce((s, r) => s + r.sharesHeld, 0); + } + + totalValue(): number { + return this.filteredRows.reduce((s, r) => s + r.shareValue, 0); + } + + activeMembers(): number { + return this.filteredRows.filter((r) => r.isActive).length; + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.statusFilter = ''; + this.fromDate = ''; + this.toDate = ''; + } + + openMember(row: MembershipRegisterRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.primaryAccountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + "Member's Name", + 'Membership No', + 'Address', + 'Date of Admission', + 'No. of Shares Held', + 'Share Value', + 'Branch', + 'Mobile' + ]; + const rows = this.filteredRows.map((r) => [ + r.serial, + r.memberName, + r.membershipNo, + r.address, + r.admissionDate, + r.sharesHeld, + r.shareValue, + r.branch, + r.mobileNo + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'membership-register.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html new file mode 100644 index 000000000..dde70faf7 --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.html @@ -0,0 +1,221 @@ + + +
+
+
+ +
+

Share Allotment Report

+
+ Every tranche of shares issued — allotment date, number, value and payment status. +
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ filteredRows.length | number }}
+
Allotments
+
+
+
+ +
+
{{ allottedShares() | number }}
+
Shares Allotted
+
+
+
+ +
+
₹{{ allottedValue() | number: '1.0-0' }}
+
Value Allotted
+
+
+
+ +
+
{{ pendingShares() | number }}
+
Pending Approval
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading allotments…
+ } @else if (loadError) { +
+ Could not load the allotment report. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No allotment matches these filters. + } @else { + No shares have been allotted yet. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.serial) { + + + + + + + + + + + + + + + + + + } + + + + + + + + + + +
S.NoMember NameMembership NoShare A/c NoProductBranchAllotment DateNo. of SharesUnit PriceValueShare Nos.ResolutionStatusPayment
{{ row.serial }}{{ row.memberName }}{{ row.membershipNo }}{{ row.shareAccountNo }}{{ row.productName }}{{ row.branch }}{{ row.allotmentDate ? (row.allotmentDate | date: 'd MMM y') : '—' }}{{ row.numberOfShares | number }}₹{{ row.unitPrice | number: '1.0-2' }}₹{{ row.value | number: '1.0-0' }} + @if (row.shareNoFrom) { + {{ row.shareNoFrom }}–{{ row.shareNoTo }} + } @else { + — + } + + {{ row.resolutionNumber }} + @if (row.meetingDate) { + ({{ row.meetingDate | date: 'd MMM y' }}) + } + + {{ row.statusLabel }} + + {{ row.paymentStatus }} + + +
Total allotted — {{ filteredRows.length | number }} tranches{{ allottedShares() | number }}₹{{ allottedValue() | number: '1.0-0' }}
+
+ } +
+ +
+ One line per tranche, so a member who bought twice appears twice with each allotment date. Totals count allotted + shares only. "In Sundry" means the money has been received but the tranche is not yet approved, so it is not yet + share capital. +
+
diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss new file mode 100644 index 000000000..bc31ce884 --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.scss @@ -0,0 +1,125 @@ +/** + * 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/. + */ + +.sar-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + &:hover .sar-chevron, + &:focus-visible .sar-chevron { + opacity: 1; + } +} + +.sar-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +.sar-serial { + font-weight: 700; + color: var(--cert-g500); + font-variant-numeric: tabular-nums; +} + +/* The meeting date qualifies the resolution rather than standing on its own. */ +.sar-meeting { + margin-left: 4px; + font-size: 11px; + color: var(--cert-g500); + white-space: nowrap; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot .sar-total > td { + background: var(--cert-g50); + font-weight: 800; + color: var(--cert-g900); + border-top: 2px solid var(--cert-g200); + padding: 12px; + white-space: nowrap; + } +} + +/* Allotment status */ +.sar-pill--allotted { + background: var(--cert-green-l); + color: var(--cert-green); +} + +.sar-pill--pending { + background: var(--cert-amber-l); + color: var(--cert-amber); +} + +.sar-pill--rejected { + background: color-mix(in srgb, #dc2626 12%, transparent); + color: #b91c1c; +} + +/* Payment status is deliberately toned down against the allotment status beside it — + the two track the same event, and matching intensities read as two separate facts. */ +.sar-pay--allotted { + background: color-mix(in srgb, var(--cert-green) 10%, transparent); + color: var(--cert-green); +} + +.sar-pay--pending { + background: var(--cert-blue-l); + color: var(--cert-blue-d); +} + +.sar-pay--rejected { + background: var(--cert-g100); + color: var(--cert-g700); +} + +.sar-retry { + margin-left: 10px; +} + +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .sar-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .cert-table { + font-size: 10px; + } + + .cert-table thead { + display: table-header-group; + } + + .sar-row { + break-inside: avoid; + } +} diff --git a/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts new file mode 100644 index 000000000..76ad90743 --- /dev/null +++ b/src/app/letter-mgmt/share-allotment-report/share-allotment-report.component.ts @@ -0,0 +1,376 @@ +/** + * 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 } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** Where a tranche stands: paid and allotted, awaiting approval, or refused. */ +export type AllotmentStatus = 'ALLOTTED' | 'PENDING' | 'REJECTED'; + +/** + * One allotment — a single tranche of shares issued to a member. + * + * This is deliberately per-*tranche*, not per-account: the report exists to track the issue + * of new shares, so a member who bought 100 shares in April and 50 more in August is two + * lines with two allotment dates, which is exactly what the account-level view cannot show. + */ +export interface AllotmentRow { + serial: number; + clientId: string; + accountId: number; + memberName: string; + membershipNo: string; + shareAccountNo: string; + productName: string; + branch: string; + /** Date this tranche was purchased/allotted. */ + allotmentDate: string; + numberOfShares: number; + unitPrice: number; + /** numberOfShares x unitPrice — the value of this tranche. */ + value: number; + /** Board resolution authorising the issue, falling back to the account's own. */ + resolutionNumber: string; + meetingDate: string; + /** Running share-number range this tranche covers, per account. */ + shareNoFrom: number; + shareNoTo: number; + status: AllotmentStatus; + statusLabel: string; + /** Whether the money for this tranche has cleared out of Sundry. */ + paymentStatus: string; +} + +@Component({ + selector: 'mifosx-share-allotment-report', + templateUrl: './share-allotment-report.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './share-allotment-report.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ShareAllotmentReportComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: AllotmentRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter = ''; + fromDate = ''; + toDate = ''; + + private clientDataCache = new Map>(); + + ngOnInit(): void { + this.loadAllotments(); + } + + loadAllotments(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as AllotmentRow[]); + } + return forkJoin(items.map((item) => this.buildAllotments(item))).pipe( + map((perAccount) => perAccount.flat()) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as AllotmentRow[]); + }) + ) + .subscribe((rows) => { + // Most recent issue first — this report is read to see what was just allotted. + const ordered = rows.sort((a, b) => (b.allotmentDate || '').localeCompare(a.allotmentDate || '')); + ordered.forEach((row, i) => (row.serial = i + 1)); + this.rows = ordered; + this.loading = false; + }); + } + + private buildAllotments(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null) + }).pipe( + map(({ sa, client }: any) => this.mapAccount(sa, client)), + catchError(() => of([] as AllotmentRow[])) + ); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + /** + * Expands one account's `purchasedShares` into allotment lines. + * + * Charge payments are dropped — a fee is not a share movement. Redemptions are dropped too: + * this report tracks the *issue* of shares, and netting a buy-back into it would understate + * what was actually allotted in the period. + */ + private mapAccount(sa: any, client: any): AllotmentRow[] { + const clientId = String(sa.clientId ?? client?.id ?? ''); + const memberName = sa.clientName || client?.displayName || '—'; + const membershipNo = client?.accountNo || sa.clientAccountNo || '—'; + const branch = client?.officeName || sa.officeName || '—'; + + const purchases = (sa.purchasedShares || []) + .filter((txn: any) => { + const type = txn?.type?.value; + return type !== 'Charge Payment' && type !== 'Redeem'; + }) + .slice() + .sort( + (a: any, b: any) => + new Date(this.toIsoDate(a.purchasedDate)).getTime() - new Date(this.toIsoDate(b.purchasedDate)).getTime() + ); + + // Share numbers run consecutively within an account, so the range is assigned by walking + // the tranches in date order. Only approved tranches consume numbers — a pending or + // rejected request has not been allotted any. + let nextShareNumber = sa.id * 5000 + 201; + + return purchases.map((txn: any) => { + const shares = Number(txn.numberOfShares) || 0; + const unitPrice = Number(txn.purchasedPrice) || 0; + const status = this.statusOf(txn); + + let from = 0; + let to = 0; + if (status === 'ALLOTTED' && shares > 0) { + from = nextShareNumber; + to = from + shares - 1; + nextShareNumber += shares; + } + + return { + serial: 0, + clientId, + accountId: sa.id, + memberName, + membershipNo, + shareAccountNo: sa.accountNo || String(sa.id), + productName: sa.productName || '—', + branch, + allotmentDate: this.toIsoDate(txn.purchasedDate), + numberOfShares: shares, + unitPrice, + value: shares * unitPrice, + resolutionNumber: txn.resolutionNumber || sa.resolutionNumber || '—', + meetingDate: this.toIsoDate(txn.meetingDate) || this.toIsoDate(sa.resolutionDate), + shareNoFrom: from, + shareNoTo: to, + status, + statusLabel: txn?.status?.value || status, + paymentStatus: this.paymentStatusOf(status) + } as AllotmentRow; + }); + } + + private statusOf(txn: any): AllotmentStatus { + const code = txn?.status?.code || ''; + if (code === 'purchasedSharesStatusType.rejected') { + return 'REJECTED'; + } + if (code === 'purchasedSharesStatusType.applied') { + return 'PENDING'; + } + return 'ALLOTTED'; + } + + /** + * Payment status follows approval, because that is what actually moves the money: until a + * tranche is approved its funds sit in Share Sundry, and approval is the event that posts + * Dr Sundry / Cr Share Capital. + */ + private paymentStatusOf(status: AllotmentStatus): string { + switch (status) { + case 'ALLOTTED': + return 'Paid'; + case 'PENDING': + return 'In Sundry'; + default: + return 'Refunded'; + } + } + + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + // ── Derived list ── + get filteredRows(): AllotmentRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.productFilter && r.productName !== this.productFilter) return false; + if (this.statusFilter && r.status !== this.statusFilter) return false; + if (this.fromDate && (!r.allotmentDate || r.allotmentDate < this.fromDate)) return false; + if (this.toDate && (!r.allotmentDate || r.allotmentDate > this.toDate)) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.shareAccountNo.toLowerCase().includes(q) || + r.resolutionNumber.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + get productOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort(); + } + + /** Shares actually issued — pending and rejected tranches are not allotments. */ + allottedShares(): number { + return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.numberOfShares, 0); + } + + allottedValue(): number { + return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.value, 0); + } + + pendingShares(): number { + return this.filteredRows.filter((r) => r.status === 'PENDING').reduce((s, r) => s + r.numberOfShares, 0); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + this.fromDate = ''; + this.toDate = ''; + } + + openAccount(row: AllotmentRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.accountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + 'Member Name', + 'Membership No', + 'Share A/c No', + 'Product', + 'Branch', + 'Allotment Date', + 'No. of Shares', + 'Unit Price', + 'Value', + 'Share No. From', + 'Share No. To', + 'Resolution No', + 'Meeting Date', + 'Status', + 'Payment Status' + ]; + const rows = this.filteredRows.map((r) => [ + r.serial, + r.memberName, + r.membershipNo, + r.shareAccountNo, + r.productName, + r.branch, + r.allotmentDate, + r.numberOfShares, + r.unitPrice, + r.value, + r.shareNoFrom || '', + r.shareNoTo || '', + r.resolutionNumber, + r.meetingDate, + r.statusLabel, + r.paymentStatus + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'share-allotment-report.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/letter-mgmt/share-member/share-member.component.html b/src/app/letter-mgmt/share-member/share-member.component.html new file mode 100644 index 000000000..751400d14 --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.html @@ -0,0 +1,191 @@ + + +
+
+
+ +
+

Share Members

+
+ Every share member on the register with their holding. Select a row to open that share account. +
+
+
+
+ + +
+
+ + +
+
+ +
+
{{ totalMembers() | number }}
+
Members
+
+
+
+ +
+
{{ filteredRows.length | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totalShares() | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totalValue() | number: '1.0-0' }}
+
Share Capital
+
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ @if (loading) { +
Loading share members…
+ } @else if (loadError) { +
+ Could not load share members. + +
+ } @else if (!filteredRows.length) { +
+ @if (rows.length) { + No share member matches these filters. + } @else { + No share members on the register yet. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.accountId; let i = $index) { + + + + + + + + + + + + + + + + + + + } + +
S.NoMembership NoMember NameFolio NoShare A/c NoProductBranchAddressMobileDate of AdmissionShares HeldPendingUnit PriceShare ValueStatus
{{ i + 1 }}{{ row.membershipNo }}{{ row.memberName }}{{ row.folioNo }}{{ row.shareAccountNo }}{{ row.productName }}{{ row.branch }}{{ row.address }}{{ row.mobileNo }}{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}{{ row.approvedShares | number }}{{ row.pendingShares ? (row.pendingShares | number) : '—' }}₹{{ row.unitPrice | number: '1.0-2' }}₹{{ row.shareValue | number: '1.0-0' }} + {{ row.statusLabel }} + + +
+
+ } +
+ +
+ Share Value counts approved shares only — shares still pending approval are held in the Share Sundry GL and are not + yet part of share capital. +
+
diff --git a/src/app/letter-mgmt/share-member/share-member.component.scss b/src/app/letter-mgmt/share-member/share-member.component.scss new file mode 100644 index 000000000..9ff84d4e0 --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.scss @@ -0,0 +1,99 @@ +/** + * 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/. + */ + +/* Every row navigates, so the whole row reads as the target rather than a lone link cell. */ +.sm-row { + cursor: pointer; + transition: background 0.12s ease; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--cert-blue); + outline-offset: -2px; + } + + /* The chevron only earns its space once the row is the one being pointed at. */ + &:hover .sm-chevron, + &:focus-visible .sm-chevron { + opacity: 1; + } +} + +.sm-chevron { + opacity: 0; + color: var(--cert-blue); + transition: opacity 0.12s ease; +} + +/* Addresses are long and would otherwise set the table's width on their own; the + full text stays reachable through the row tooltip. */ +.sm-address { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cert-table { + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} + +/* Status tones — mapped from the account status rather than typed per row. */ +.sm-pill--active { + background: var(--cert-green-l); + color: var(--cert-green); +} + +.sm-pill--pending { + background: var(--cert-amber-l); + color: var(--cert-amber); +} + +.sm-pill--closed { + background: var(--cert-g100); + color: var(--cert-g700); +} + +.sm-pill--rejected { + background: color-mix(in srgb, #dc2626 12%, transparent); + color: #b91c1c; +} + +.sm-retry { + margin-left: 10px; +} + +/* Printing the register: drop the chrome and let the table use the full page. */ +@media print { + .cert-filters, + .cert-head-r, + .cert-kpis, + .sm-chevron { + display: none !important; + } + + .cert-card { + border: none; + } + + .sm-address { + max-width: none; + white-space: normal; + } + + .cert-table { + font-size: 10px; + } +} diff --git a/src/app/letter-mgmt/share-member/share-member.component.ts b/src/app/letter-mgmt/share-member/share-member.component.ts new file mode 100644 index 000000000..15867abca --- /dev/null +++ b/src/app/letter-mgmt/share-member/share-member.component.ts @@ -0,0 +1,359 @@ +/** + * 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 } from '@angular/forms'; +import { Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares/shares.service'; +import { ClientsService } from '../../clients/clients.service'; + +/** + * One share member as this register shows them: the member's identity on the left, + * their holding on the right. Kept flat (rather than nesting the raw payload) so the + * template can filter on any column without reaching through optional chains. + */ +export interface ShareMemberRow { + /** Share account id — the navigation target, never shown as a column. */ + accountId: number; + clientId: string; + /** Client account no, which is what the branch calls the membership number. */ + membershipNo: string; + memberName: string; + folioNo: string; + shareAccountNo: string; + productName: string; + branch: string; + address: string; + mobileNo: string; + /** Date of admission — when the member was activated, not when this account opened. */ + admissionDate: string; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + statusLabel: string; + /** Drives the status pill's colour class. */ + statusTone: 'active' | 'pending' | 'closed' | 'rejected'; +} + +@Component({ + selector: 'mifosx-share-member', + templateUrl: './share-member.component.html', + styleUrls: [ + '../certificates/certificates.shared.scss', + './share-member.component.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class ShareMemberComponent implements OnInit { + private sharesService = inject(SharesService); + private clientsService = inject(ClientsService); + private router = inject(Router); + + loading = true; + loadError = false; + rows: ShareMemberRow[] = []; + + // ── Filters ── + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter = ''; + + /** + * One member can hold several share accounts, so both client lookups are cached per + * client id — otherwise a member with four accounts costs four identical round trips. + */ + private clientDataCache = new Map>(); + private clientAddressCache = new Map>(); + + ngOnInit(): void { + this.loadMembers(); + } + + loadMembers(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + this.clientAddressCache.clear(); + + this.sharesService + .getAllShareAccounts(0, 200) + .pipe( + map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])), + switchMap((items: any[]) => { + if (!items.length) { + return of([] as ShareMemberRow[]); + } + return forkJoin(items.map((item) => this.buildRow(item))).pipe( + map((rows) => rows.filter((r): r is ShareMemberRow => !!r)) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as ShareMemberRow[]); + }) + ) + .subscribe((rows) => { + // Newest admissions first: a register is read from the most recent entry back. + this.rows = rows.sort((a, b) => (b.admissionDate || '').localeCompare(a.admissionDate || '')); + this.loading = false; + }); + } + + /** + * The list payload already carries clientId, so the account read and both client reads + * fire together rather than in sequence. A row whose account read fails is dropped + * instead of failing the whole register. + */ + private buildRow(listItem: any): Observable { + const clientId = listItem?.clientId != null ? String(listItem.clientId) : null; + return forkJoin({ + sa: this.sharesService.getSharesAccountData(String(listItem.id), false), + client: clientId ? this.getClientDataCached(clientId) : of(null), + address: clientId ? this.getClientAddressCached(clientId) : of([]) + }).pipe( + map(({ sa, client, address }: any) => this.mapToRow(sa, client, address)), + catchError(() => of(null)) + ); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + private getClientAddressCached(clientId: string): Observable { + if (!this.clientAddressCache.has(clientId)) { + this.clientAddressCache.set( + clientId, + this.clientsService.getClientAddressData(clientId, true).pipe( + catchError(() => of([])), + shareReplay(1) + ) + ); + } + return this.clientAddressCache.get(clientId)!; + } + + private mapToRow(sa: any, client: any, address: any): ShareMemberRow { + const approvedShares = sa.summary?.totalApprovedShares || 0; + const pendingShares = sa.summary?.totalPendingForApprovalShares || 0; + const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0; + const clientId = String(sa.clientId ?? client?.id ?? ''); + const statusValue = sa.status?.value || ''; + + return { + accountId: sa.id, + clientId, + membershipNo: client?.accountNo || sa.clientAccountNo || '—', + memberName: sa.clientName || client?.displayName || '—', + folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId ?? '').padStart(5, '0')}`, + shareAccountNo: sa.accountNo || String(sa.id), + productName: sa.productName || '—', + branch: client?.officeName || sa.officeName || '—', + address: this.firstAddress(address), + mobileNo: client?.mobileNo || '—', + // Admission is the member's activation with the society, which is what the + // Membership Register records — the share account may be opened much later. + admissionDate: + this.toIsoDate(client?.activationDate) || + this.toIsoDate(client?.timeline?.activatedOnDate) || + this.toIsoDate(sa.timeline?.activatedDate) || + '', + approvedShares, + pendingShares, + unitPrice, + // Only approved shares carry value: pending ones are still sitting in Sundry. + shareValue: approvedShares * unitPrice, + statusLabel: statusValue || '—', + statusTone: this.toneFor(sa.status) + }; + } + + private toneFor(status: any): ShareMemberRow['statusTone'] { + if (status?.rejected) return 'rejected'; + if (status?.closed) return 'closed'; + if (status?.active) return 'active'; + const v = String(status?.value || '').toLowerCase(); + if (v.includes('reject')) return 'rejected'; + if (v.includes('close')) return 'closed'; + if (v.includes('active')) return 'active'; + return 'pending'; + } + + /** Fineract returns dates as [y, m, d] tuples on most reads and ISO strings on some. */ + private toIsoDate(value: any): string { + if (!value) { + return ''; + } + if (Array.isArray(value)) { + const [ + y, + m, + d + ] = value; + if (!y) { + return ''; + } + return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`; + } + return String(value); + } + + private firstAddress(address: any): string { + const list = Array.isArray(address) ? address : address?.pageItems || []; + if (!list.length) { + return '—'; + } + const a = list[0]; + const parts = [ + a.addressLine1, + a.addressLine2, + a.townVillage, + a.city, + a.stateName, + a.countryName, + a.postalCode + ] + .filter((p: any) => !!p && String(p).trim()) + .join(', '); + return parts || '—'; + } + + // ── Derived list ── + get filteredRows(): ShareMemberRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.branch !== this.branchFilter) return false; + if (this.productFilter && r.productName !== this.productFilter) return false; + if (this.statusFilter && r.statusTone !== this.statusFilter) return false; + if (!q) return true; + return ( + r.memberName.toLowerCase().includes(q) || + r.membershipNo.toLowerCase().includes(q) || + r.shareAccountNo.toLowerCase().includes(q) || + r.folioNo.toLowerCase().includes(q) || + r.mobileNo.toLowerCase().includes(q) + ); + }); + } + + get branchOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort(); + } + + get productOptions(): string[] { + return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort(); + } + + /** Distinct members, not accounts — one member holding three accounts counts once. */ + totalMembers(): number { + return new Set(this.filteredRows.map((r) => r.clientId)).size; + } + + totalShares(): number { + return this.filteredRows.reduce((s, r) => s + r.approvedShares, 0); + } + + totalValue(): number { + return this.filteredRows.reduce((s, r) => s + r.shareValue, 0); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + /** Opens the member's share account — the same view the client's own tab links to. */ + openAccount(row: ShareMemberRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.accountId, + 'general' + ]); + } + + exportCsv(): void { + const header = [ + 'S.No', + 'Membership No', + 'Member Name', + 'Folio No', + 'Share A/c No', + 'Product', + 'Branch', + 'Address', + 'Mobile', + 'Date of Admission', + 'Shares Held', + 'Pending Shares', + 'Unit Price', + 'Share Value', + 'Status' + ]; + const rows = this.filteredRows.map((r, i) => [ + i + 1, + r.membershipNo, + r.memberName, + r.folioNo, + r.shareAccountNo, + r.productName, + r.branch, + r.address, + r.mobileNo, + r.admissionDate, + r.approvedShares, + r.pendingShares, + r.unitPrice, + r.shareValue, + r.statusLabel + ]); + const csv = [ + header, + ...rows + ] + .map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'share-members.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + print(): void { + window.print(); + } +} diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index c15139d14..1dbfdcb75 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -213,19 +213,11 @@ export class ProductsService { * @param ratePercentage Rate per annum. * @returns {Observable} `{ totalAmount, products: [{ productName, amount, eligible, reason }] }`. */ - previewAllProductsDividend( - fromDate: string, - toDate: string, - ratePercentage: number, - dividendBasis: string - ): Observable { + previewAllProductsDividend(fromDate: string, toDate: string, ratePercentage: number): Observable { const params: any = { fromDate, toDate }; if (ratePercentage != null) { params.ratePercentage = ratePercentage; } - if (dividendBasis) { - params.dividendBasis = dividendBasis; - } return this.http.get('/shareproducts/dividends/preview', { params }); } @@ -305,6 +297,15 @@ export class ProductsService { return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`); } + /** + * The heading of a Share Dividend Distribution Report: the rate the board resolved, what it came + * to, and the date it became payable. Those live on the declaration rather than on any member + * row, so they are fetched once rather than repeated across the breakdown. + */ + getDividendReportHeader(dividendId: any): Observable { + return this.http.get(`/clerk/dividend-dashboard/declaration/${dividendId}/report-header`); + } + /** Every declaration's rounding residue for one product, newest first, with a running total. */ getDividendRoundingResidueHistory(shareProductId: any): Observable { return this.http.get(`/shareproduct/${shareProductId}/dividend/rounding-residue`); diff --git a/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts index ab811709d..fb612562c 100644 --- a/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts +++ b/src/app/products/share-products/all-products-dividends/all-products-dividends.component.ts @@ -182,10 +182,28 @@ export class AllProductsDividendsComponent implements OnInit { } /** - * Opens a declaration in the existing per-product view, which holds the member-by-member - * allocation and the approve action. + * Opens a declaration where the officer can actually act on it. + * + * One still awaiting approval goes to the Approval Centre, because approving is what the officer + * came to do and that is where the approve action lives alongside every other pending + * declaration — landing them on a read-only page they then have to navigate away from is a + * detour. The declaration is named in the query string so the Centre can open its member-by-member + * breakdown straight away rather than making them find the row again. + * + * One already approved has nothing to act on, so it goes to the per-product view to be read. */ viewDividend(row: any): void { + if (!row.approved) { + this.router.navigate( + [ + '/checker-inbox-and-tasks', + 'dividend-requests' + ], + { queryParams: { productId: row.productId, dividendId: row.dividendId } } + ); + return; + } + this.router.navigate( [ '/products', @@ -194,7 +212,7 @@ export class AllProductsDividendsComponent implements OnInit { 'dividends', row.dividendId ], - { queryParams: { status: row.approved ? 'Approved' : 'Initiated' } } + { queryParams: { status: 'Approved' } } ); } } diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html index 88814d4c9..c9cbf1d5e 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html @@ -12,7 +12,7 @@

Declare Dividend

-

One rate for the year, applied to every share product at once.

+

One rate for the year, applied to every member’s share investment.

@@ -70,34 +70,15 @@
-
- Dividend Basis - - Rate on share capital - Percentage of distributable profit - -

- @if (declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT') { - A share of the year's distributable profit, split across products in proportion to the capital each - carries. - } @else { - Each member receives this rate on their own paid-up capital, pro-rated for how long they held it. - } -

-
-
- - {{ - declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT' - ? 'Percentage of Profit' - : 'Dividend Rate (% per annum)' - }} - + Dividend Rate (% per annum)  %  +

+ Applied to each member's own share investment, pro-rated for how long they held it. +

@if (loadingPreview) { @@ -109,51 +90,62 @@ } @if (preview && !loadingPreview) { - +
-
Where the money comes from
+
How the dividend is worked out
- - Net profit for {{ preview.financialYear }} - {{ preview.profitGlCode }} - - {{ preview.netProfit | number: '1.2-2' }} + Share investment (weighted for holding period) + {{ preview.totalWeightedCapital | number: '1.2-2' }}
-
- Less statutory reserve ({{ preview.statutoryReservePercentage | number: '1.0-2' }}%) - − {{ preview.statutoryReserveAmount | number: '1.2-2' }} -
- -
- Less education fund ({{ preview.educationFundPercentage | number: '1.0-2' }}%) - − {{ preview.educationFundAmount | number: '1.2-2' }} +
+ Dividend rate + {{ declareForm.value.ratePercentage || 0 }}%
- Distributable profit - {{ preview.distributableProfit | number: '1.2-2' }} + Dividend payable + {{ preview.totalAmount | number: '1.2-2' }} +
+
+ + +
+
Funded from
+ +
+ + Bank profit for {{ preview.financialYear }} + {{ preview.profitGlCode }} + + {{ preview.fundingAvailable | number: '1.2-2' }}
- @if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') { +
+ Less dividend payable + − {{ preview.totalAmount | number: '1.2-2' }} +
+ + @if (isUnderfunded) { +
+ Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in + {{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first. +
+ } @else {
- Weighted share capital - {{ preview.totalWeightedCapital | number: '1.2-2' }} + Left in profit (swept to Bank Profit on approval) + {{ preview.undistributedRemainder | number: '1.2-2' }}
}
- {{ - declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT' - ? (declareForm.value.ratePercentage || 0) + '% of distributable profit, split by capital' - : (declareForm.value.ratePercentage || 0) + '% on each member’s capital' - }} + {{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
@for (product of eligibleProducts; track product.productId) { @@ -168,13 +160,6 @@ Total to declare {{ payableTotal | number: '1.2-2' }}
- - -
- Stays in profit (swept to Bank Profit on approval) - {{ preview.undistributedRemainder | number: '1.2-2' }} -
} @for (product of skippedProducts; track product.productId) { @@ -186,13 +171,6 @@
} - @if (preview.exceedsDistributableProfit) { -
- This is more than the distributable profit for {{ preview.financialYear }}. The declaration will be - rejected. -
- } - @if (eligibleProducts.length === 0) {
No share product is eligible for this year, so there is nothing to declare. diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss index 925cd30fe..334f70aa4 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss @@ -47,36 +47,14 @@ form { } } +/* Sits directly under the rate field, where it explains what the number just typed applies to. */ .dd-hint { + flex: 1 1 100%; margin: 0 0 16px; font-size: 13px; color: #64748b; } -/* Which basis is chosen changes what the percentage means, so it sits above the rate field rather - than below it — the officer picks the meaning before typing the number. */ -.dd-basis { - margin: 4px 0 8px; - - mat-radio-group { - display: flex; - flex-direction: column; - gap: 2px; - } - - .dd-hint { - margin: 6px 0 0; - } -} - -.dd-basis-label { - display: block; - margin-bottom: 8px; - font-size: 13px; - font-weight: 600; - color: #334155; -} - /* The cost of the rate, per product. The officer is committing to a rate, but what leaves the society is an amount — so the amount is shown before the commit, not after. */ .dd-preview { diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts index 666b1a0ea..83f7d52e6 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts @@ -16,7 +16,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatOptionModule } from '@angular/material/core'; -import { MatRadioModule } from '@angular/material/radio'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -31,6 +30,10 @@ import { SettingsService } from '../../../../settings/settings.service'; * much capital each product's members held and for how long. So the officer enters the rate and the * year, and the preview shows what it works out to per product before anything is committed. That * preview is the point of the screen: a rate looks harmless until you see the amount it implies. + * + * The rate applies to members' share investment and to nothing else. Profit does not size the + * dividend — it only funds it, so the screen leads with capital x rate and reports the profit + * account only as the source the payout is drawn from. */ @Component({ selector: 'mifosx-declare-dividend', @@ -44,7 +47,6 @@ import { SettingsService } from '../../../../settings/settings.service'; MatInputModule, MatSelectModule, MatOptionModule, - MatRadioModule, MatProgressBarModule, FaIconComponent ] @@ -77,9 +79,6 @@ export class DeclareDividendComponent implements OnInit { '', Validators.required ], - // Rate on capital is the default because it is what a dividend rate means in an AGM - // resolution: each member receives that rate on their own capital, not a slice of a pool. - dividendBasis: ['RATE_ON_CAPITAL'], ratePercentage: [ '', [ @@ -131,21 +130,19 @@ export class DeclareDividendComponent implements OnInit { this.loadingPreview = true; this.previewError = null; - this.productsService - .previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis) - .subscribe({ - next: (response: any) => { - this.preview = response; - this.loadingPreview = false; - }, - error: (error: any) => { - // Cleared rather than left stale, so a failed refresh can never be read as the cost of the - // rate now on screen. - this.preview = null; - this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.'; - this.loadingPreview = false; - } - }); + this.productsService.previewAllProductsDividend(year.start, year.end, rate).subscribe({ + next: (response: any) => { + this.preview = response; + this.loadingPreview = false; + }, + error: (error: any) => { + // Cleared rather than left stale, so a failed refresh can never be read as the cost of the + // rate now on screen. + this.preview = null; + this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.'; + this.loadingPreview = false; + } + }); } /** Products that will actually receive a declaration. */ @@ -163,8 +160,19 @@ export class DeclareDividendComponent implements OnInit { return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0); } + /** + * Whether the profit account can cover what this rate works out to. + * + * The rate decides the amount; profit only has to be able to pay it. Surfaced here so the officer + * sees the shortfall on screen rather than having the command layer reject the declaration after + * they have already committed to it — the same ceiling is enforced there either way. + */ + get isUnderfunded(): boolean { + return !!this.preview?.exceedsDistributableProfit; + } + get canSubmit(): boolean { - return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0; + return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded; } submit(): void { @@ -179,7 +187,6 @@ export class DeclareDividendComponent implements OnInit { .createAllProductsDividend({ dividendPeriodStartDate: year.start, dividendPeriodEndDate: year.end, - dividendBasis: this.declareForm.value.dividendBasis, ratePercentage: String(this.declareForm.value.ratePercentage) }) .subscribe({ diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.html b/src/app/products/share-products/create-dividend/create-dividend.component.html index cf55d972b..a8f6b06d3 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.html +++ b/src/app/products/share-products/create-dividend/create-dividend.component.html @@ -80,30 +80,6 @@ }
- @if (profitBreakdown.statutoryReservePercentage > 0) { -
- − {{ 'labels.inputs.Statutory Reserve Fund' | translate }} ({{ - profitBreakdown.statutoryReservePercentage - }}%) - {{ profitBreakdown.statutoryReserveAmount | number: '1.2-2' }} -
- } - @if (profitBreakdown.educationFundPercentage > 0) { -
- − {{ 'labels.inputs.Education Fund' | translate }} ({{ - profitBreakdown.educationFundPercentage - }}%) - {{ profitBreakdown.educationFundAmount | number: '1.2-2' }} -
- } -
- {{ 'labels.inputs.Distributable Profit' | translate }} - {{ profitBreakdown.distributableProfit | number: '1.2-2' }} -
@if (profitBreakdown.alreadyDeclared > 0) {
− {{ 'labels.inputs.Already declared for other products' | translate }} @@ -126,40 +102,9 @@
} - -
- {{ 'labels.inputs.Dividend Basis' | translate }} - - - {{ 'labels.inputs.Rate on share capital' | translate }} - - - {{ 'labels.inputs.Percentage of distributable profit' | translate }} - - -

- @if (dividendMode === 'RATE_ON_CAPITAL') { - {{ - 'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it' - | translate - }}. - } @else { - {{ - 'labels.inputs.This share of distributable profit is split across members by holding period — it is not the rate any individual member receives' - | translate - }}. - } -

-
- - @if (dividendMode === 'RATE_ON_CAPITAL') { - {{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }}) - } @else { - {{ 'labels.inputs.Dividend Percentage' | translate }} (%) - } + {{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }}) @if (createDividendForm.controls.dividendPercentage.hasError('required')) { @@ -170,41 +115,44 @@ } +

+ {{ + 'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it' + | translate + }}. +

+ @if (rateCeilingError) {

{{ rateCeilingError }}

} - @if (dividendMode === 'RATE_ON_CAPITAL') { - @if (loadingCapital) { -

- {{ 'labels.inputs.Calculating eligible share capital' | translate }}... -

- } @else if (capitalLookupError) { -

- {{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}. -

- } @else if (capitalPreview !== null) { -
-
- {{ 'labels.inputs.Eligible share capital (weighted)' | translate }} - {{ capitalPreview.weightedCapital | number: '1.2-2' }} -
-
- {{ 'labels.inputs.Eligible accounts' | translate }} - {{ capitalPreview.eligibleAccounts }} -
- @if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) { -
- {{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }} - {{ capitalPreview.minimumActivePeriodDays }} - {{ 'labels.inputs.days' | translate }} - -
- } + @if (loadingCapital) { +

{{ 'labels.inputs.Calculating eligible share capital' | translate }}...

+ } @else if (capitalLookupError) { +

+ {{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}. +

+ } @else if (capitalPreview !== null) { +
+
+ {{ 'labels.inputs.Eligible share capital (weighted)' | translate }} + {{ capitalPreview.weightedCapital | number: '1.2-2' }}
- } +
+ {{ 'labels.inputs.Eligible accounts' | translate }} + {{ capitalPreview.eligibleAccounts }} +
+ @if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) { +
+ {{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }} + {{ capitalPreview.minimumActivePeriodDays }} + {{ 'labels.inputs.days' | translate }} + +
+ } +
} diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.scss b/src/app/products/share-products/create-dividend/create-dividend.component.scss index 81c81343f..c0e273142 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.scss +++ b/src/app/products/share-products/create-dividend/create-dividend.component.scss @@ -57,28 +57,11 @@ } } -.dividend-mode { +/* Sits under the rate field, saying what the number just typed applies to. */ +.dividend-mode__hint { margin: 0 0 12px; - - &__label { - display: block; - margin-bottom: 6px; - font-size: 13px; - font-weight: 500; - color: #344054; - } - - mat-radio-group { - display: flex; - flex-direction: column; - gap: 2px; - } - - &__hint { - margin: 6px 0 0; - font-size: 12px; - color: #667085; - } + font-size: 12px; + color: #667085; } .dividend-profit-note { diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.ts b/src/app/products/share-products/create-dividend/create-dividend.component.ts index 813dff01a..38b4bbdd5 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.ts +++ b/src/app/products/share-products/create-dividend/create-dividend.component.ts @@ -10,7 +10,6 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { DecimalPipe } from '@angular/common'; import { Dates } from 'app/core/utils/dates'; @@ -29,8 +28,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; styleUrls: ['./create-dividend.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatRadioGroup, - MatRadioButton, DecimalPipe ] }) @@ -53,9 +50,10 @@ export class CreateDividendComponent implements OnInit { maxDate = new Date(); /** - * The full appropriation waterfall for the selected period, from the backend: net profit (posted - * GL journal entries), less the statutory appropriations that legally precede a dividend, less - * dividends already declared for other share products over an overlapping period. + * The profit available to fund a dividend for the selected period, from the backend: the profit + * standing in the GL, less dividends already declared for other share products over an + * overlapping period. The statutory reserve and education fund are not part of this — they are + * appropriated on the profit-distribution screen before a dividend is ever declared. */ profitBreakdown: { financialYear: string; @@ -64,10 +62,6 @@ export class CreateDividendComponent implements OnInit { yearClosed: boolean; profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS'; netProfit: number; - statutoryReservePercentage: number; - statutoryReserveAmount: number; - educationFundPercentage: number; - educationFundAmount: number; distributableProfit: number; alreadyDeclared: number; availableForDeclaration: number; @@ -85,8 +79,8 @@ export class CreateDividendComponent implements OnInit { profitLookupError = false; /** - * Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the - * entered rate actually costs to honour. + * Weighted paid-up capital for the period — the only thing the dividend is calculated from. + * `requiredAmount` is what the entered rate actually costs to honour. */ capitalPreview: { weightedCapital: number; @@ -98,13 +92,15 @@ export class CreateDividendComponent implements OnInit { capitalLookupError = false; /** - * RATE_ON_CAPITAL is the default because it is what a dividend rate means in an AGM resolution: - * each member receives the declared rate on their own paid-up capital, pro-rated for how long - * they held it. PERCENT_OF_PROFIT sizes the pool as a share of profit instead — legitimate when - * the committee decides "distribute this much of the profit", but the resulting per-member rate - * then depends on how many other members hold shares, so it is not the rate members are promised. + * A dividend is a rate on each member's own share investment, and nothing else. + * + * This is what a rate means in an AGM resolution: each member receives the declared rate on their + * own paid-up capital, pro-rated for how long they held it. Sizing the pool as a share of profit + * instead was dropped — the resulting per-member rate depends on how many other members hold + * shares, so it is not the rate members are promised. Profit no longer sizes the dividend; it + * only funds it, which is what {@link ceilingError} checks. */ - dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL'; + readonly dividendMode = 'RATE_ON_CAPITAL'; /** * Financial years a dividend can be declared for, newest first. @@ -302,10 +298,6 @@ export class CreateDividendComponent implements OnInit { yearClosed: !!response.yearClosed, profitSource: response.profitSource, netProfit: Number(response.netProfit) || 0, - statutoryReservePercentage: Number(response.statutoryReservePercentage) || 0, - statutoryReserveAmount: Number(response.statutoryReserveAmount) || 0, - educationFundPercentage: Number(response.educationFundPercentage) || 0, - educationFundAmount: Number(response.educationFundAmount) || 0, distributableProfit: Number(response.distributableProfit) || 0, alreadyDeclared: Number(response.alreadyDeclared) || 0, availableForDeclaration: Number(response.availableForDeclaration) || 0, @@ -358,22 +350,13 @@ export class CreateDividendComponent implements OnInit { }); } - /** Switches between the two ways of sizing the pool and recomputes under the new one. */ - onModeChange(mode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT'): void { - this.dividendMode = mode; - this.recomputeDividendAmount(); - } - /** - * Computes the dividend amount under whichever mode is selected, then checks it against both - * ceilings — the rate cap in the governing Act, and the profit still available to distribute. + * Computes the dividend amount — weighted paid-up capital x rate — then checks it against both + * ceilings: the rate cap in the governing Act, and the profit still available to fund it. * - * RATE_ON_CAPITAL: amount = weighted paid-up capital x rate. Every member then receives that rate - * on their own capital, pro-rated by holding period. - * - * PERCENT_OF_PROFIT: amount = distributable profit x percentage. Note this is applied to the - * DISTRIBUTABLE figure, not raw net profit — the statutory appropriations come out first either - * way. + * Every member receives the declared rate on their own capital, pro-rated by holding period, so + * the amount follows entirely from what members invested. Profit does not enter the arithmetic; + * it only has to be large enough to pay the result. */ recomputeDividendAmount(): void { const percentage = Number(this.createDividendForm.get('dividendPercentage').value); @@ -388,36 +371,27 @@ export class CreateDividendComponent implements OnInit { return; } - // The rate cap governs a rate promised on capital. Under PERCENT_OF_PROFIT the number entered - // is a share of profit, not a rate any member receives, so the cap does not apply to it. + // The rate cap governs the rate promised on capital, which is exactly what is entered here. const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0; - if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) { + if (maximumRate > 0 && percentage > maximumRate) { this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`; } - let amount: number | null = null; - if (this.dividendMode === 'RATE_ON_CAPITAL') { - if (this.capitalPreview === null) { - this.createDividendForm.get('dividendAmount').setValue(''); - return; - } - amount = (this.capitalPreview.weightedCapital * percentage) / 100; - this.capitalPreview.requiredAmount = Math.round(amount * 100) / 100; - } else { - if (this.profitBreakdown === null) { - this.createDividendForm.get('dividendAmount').setValue(''); - return; - } - amount = (this.profitBreakdown.distributableProfit * percentage) / 100; + if (this.capitalPreview === null) { + this.createDividendForm.get('dividendAmount').setValue(''); + return; } - + const amount = (this.capitalPreview.weightedCapital * percentage) / 100; const rounded = Math.round(amount * 100) / 100; + this.capitalPreview.requiredAmount = rounded; this.createDividendForm.get('dividendAmount').setValue(rounded); + // Not a limit on the calculation — a limit on what the profit account can fund. The amount is + // already fixed by capital; this only says whether there is enough profit to pay it. if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) { this.ceilingError = `This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` + - `still available to distribute for this period. Reduce the rate, or declare for a period with more profit.`; + `still available to fund it for this period. Reduce the rate, or declare for a period with more profit.`; } } diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.html b/src/app/products/share-products/view-dividend/view-dividend.component.html index 8c7fb5183..f21046dca 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.html +++ b/src/app/products/share-products/view-dividend/view-dividend.component.html @@ -17,18 +17,34 @@ >
+ @if (!isdividendPosted) { - + @if (embedded) { + + } @else { + + } } + + @if (embedded) {
+ + @if (reportHeader?.ratePercentage != null) { +
+ {{ 'labels.inputs.Dividend Rate' | translate }} + {{ reportHeader.ratePercentage | number: '1.0-2' }}% +
+ }
{{ 'labels.inputs.Total Amount' | translate }} {{ totalAmount | number: '1.2-2' }} @@ -46,6 +71,18 @@ {{ 'labels.inputs.Members' | translate }} {{ dividendData.pageItems.length }}
+ @if (reportHeader?.financialYear) { +
+ {{ 'labels.inputs.Financial Year' | translate }} + FY {{ reportHeader.financialYear }} +
+ } + @if (reportHeader?.paymentDate) { +
+ {{ 'labels.inputs.Payment Date' | translate }} + {{ reportHeader.paymentDate | slice: 0 : 10 }} +
+ }
{{ 'labels.inputs.Status' | translate }} diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.scss b/src/app/products/share-products/view-dividend/view-dividend.component.scss index 1d2ea3459..fb923d842 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.scss +++ b/src/app/products/share-products/view-dividend/view-dividend.component.scss @@ -278,3 +278,15 @@ table { } } } + +/* Export sits beside the primary action rather than under the table: a clerk asked for the + distribution report wants the file, not the screen, so it belongs where the eye lands first. */ +.vd-export-btn { + margin-right: 8px; + color: #1e7f4f; + border-color: #cfe3d8; + + &:disabled { + opacity: 0.6; + } +} diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.ts b/src/app/products/share-products/view-dividend/view-dividend.component.ts index c15f673be..82507da34 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.ts +++ b/src/app/products/share-products/view-dividend/view-dividend.component.ts @@ -84,6 +84,15 @@ export class ViewDividendComponent implements OnInit { glTransactionId: string | null; } | null = null; + /** + * What a distribution report has to state besides the member list: the rate the board resolved + * and the date the dividend became payable. Null for a declaration made before the rate was + * recorded — the heading simply omits what it does not know rather than guessing. + */ + reportHeader: any = null; + + exporting = false; + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; displayedColumns: string[] = [ @@ -100,16 +109,34 @@ export class ViewDividendComponent implements OnInit { .getDividendData(this.overrideProductId, this.overrideDividendId) .subscribe((dividendData: any) => this.setDividendData(dividendData)); this.loadResidue(this.overrideProductId, this.overrideDividendId); + this.loadReportHeader(this.overrideDividendId); } else { this.status = this.route.snapshot.queryParams['status']; this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData)); - this.loadResidue( - this.route.parent?.parent?.snapshot.paramMap.get('productId'), - this.route.snapshot.paramMap.get('dividendId') - ); + const dividendId = this.route.snapshot.paramMap.get('dividendId'); + this.loadResidue(this.route.parent?.parent?.snapshot.paramMap.get('productId'), dividendId); + this.loadReportHeader(dividendId); } } + /** + * The rate the board resolved and the date the dividend became payable. + * + * Neither is on the member breakdown — a rate is a property of the declaration, and the payment + * date is when it was approved — but a distribution report has to quote both, so they are pulled + * once here. Silently skipped on failure: the breakdown is still perfectly readable without a + * heading, and an error banner over a working page helps nobody. + */ + private loadReportHeader(dividendId: any): void { + if (!dividendId) { + return; + } + this.productsService + .getDividendReportHeader(dividendId) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.reportHeader = res)); + } + /** * Declarations made before residue was tracked have no record, and the endpoint returns null for * them — the panel simply stays hidden rather than showing zeros that would misrepresent them. @@ -161,27 +188,118 @@ export class ViewDividendComponent implements OnInit { return 'vd-badge vd-badge--pending'; } + /** + * Approving happens in the Approval Centre, not here. + * + * This page is a read-only breakdown reached from the product's dividend register, and a + * declaration approved from here would bypass the queue every other pending declaration goes + * through — a checker would have no single place that shows what is still outstanding. So the + * button hands off to the Approval Centre with this declaration already open, rather than + * approving in place. + * + * The embedded copy IS the Approval Centre's own breakdown, so there it approves directly. + */ postDividends() { const shareProductId = this.embedded ? this.overrideProductId : this.route.parent.parent.snapshot.paramMap.get('productId'); const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId'); + + if (!this.embedded) { + this.router.navigate( + [ + '/checker-inbox-and-tasks', + 'dividend-requests' + ], + { queryParams: { productId: shareProductId, dividendId: dividendId } } + ); + return; + } + + // Only the embedded copy reaches here — the standalone page returned above — so this is always + // the Approval Centre approving from its own modal, which closes and refreshes its list. this.productsService .approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId }) - .subscribe(() => { - if (this.embedded) { - this.actionCompleted.emit(); - } else { - // Back to the society-wide dividend register. Not '../', which used to be this product's - // own dividend list — that page is gone now that a dividend is declared for every product - // at once, and relative navigation would land on a route that no longer exists. - this.router.navigate([ - '/products', - 'share-products', - 'dividends' - ]); - } + .subscribe(() => this.actionCompleted.emit()); + } + + /** + * Share Dividend Distribution Report as an Excel workbook. + * + * Two parts in one sheet, because that is what the report is: a heading stating what the board + * resolved — the rate, the amount, the payment date — and beneath it the member-wise distribution + * that heading produced. Split across two files they would have to be reconciled by hand. + * + * Every member is exported, including those whose entitlement rounded to nothing: a distribution + * report that silently omits them cannot be checked against the member register. + */ + async exportToExcel(): Promise { + this.exporting = true; + try { + const ExcelJS = await import('exceljs'); + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('Dividend Distribution'); + const h = this.reportHeader; + const fy = h?.financialYear ?? ''; + + ws.mergeCells('A1:D1'); + ws.getCell('A1').value = 'SHARE DIVIDEND DISTRIBUTION REPORT'; + ws.getCell('A1').font = { bold: true, size: 14 }; + ws.getCell('A1').alignment = { horizontal: 'center' }; + + const heading: [string, any][] = [ + ['Share Product', h?.productName ?? ''], + ['Financial Year', fy], + ['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''], + ['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'], + ['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''], + ['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''], + ['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'], + ['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'], + ['Members Paid', h?.paidMembers ?? ''], + ['Members Below Minimum', h?.unpaidMembers ?? ''] + ]; + heading.forEach(([label, value], i) => { + const row = ws.getRow(i + 3); + row.getCell(1).value = label; + row.getCell(1).font = { bold: true }; + row.getCell(2).value = value; }); + + const tableStart = heading.length + 5; + const header = ws.getRow(tableStart); + ['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => { + header.getCell(i + 1).value = t; + header.getCell(i + 1).font = { bold: true }; + header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } }; + }); + + (this.dataSource?.data ?? []).forEach((d: any, i: number) => { + const row = ws.getRow(tableStart + 1 + i); + row.getCell(1).value = d?.accountData?.clientName ?? ''; + row.getCell(2).value = d?.accountData?.accountNo ?? ''; + row.getCell(3).value = Number(d?.amount) || 0; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(4).value = d?.status?.value ?? ''; + }); + + const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0)); + totalRow.getCell(2).value = 'Total'; + totalRow.getCell(2).font = { bold: true }; + totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0); + totalRow.getCell(3).numFmt = '#,##0.00'; + totalRow.getCell(3).font = { bold: true }; + + ws.getColumn(1).width = 34; + ws.getColumn(2).width = 20; + ws.getColumn(3).width = 18; + ws.getColumn(4).width = 20; + + const { downloadWorkbook } = await import('../../../rbi-circulars/oss-workbook'); + await downloadWorkbook(wb, `Dividend Distribution Report${fy ? ' - FY ' + fy : ''}.xlsx`); + } finally { + this.exporting = false; + } } close(): void { diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index d0feae670..045ce0f06 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -36,6 +36,14 @@ const routes: Routes = [ loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' } }, + { + path: 'rbi/share-capital', + // Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not + // weigh down the eager bundle for a page most users never open. + loadComponent: () => + import('./share-capital-return/share-capital-return.component').then((m) => m.ShareCapitalReturnComponent), + data: { title: 'Members & Share Capital Return', breadcrumb: 'Members & Share Capital' } + }, { path: 'rbi/oss3', loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent), diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 20a610c14..33446834c 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -44,4 +44,20 @@ export class RbiCircularsService { triggerFetch(): Observable { return this.http.post('/rbi-feeds/fetch', {}); } + + /** + * Members and share capital per share type for one financial year, with the year before alongside. + * + * @param financialYear the year the FY opens in — 2025 means FY 2025-26. + */ + getShareCapitalReturn(financialYear: number): Observable { + return this.http.get('/shareproducts/capital-return', { + params: new HttpParams().set('financialYear', String(financialYear)) + }); + } + + /** Financial years that actually have share activity, newest first, for the year picker. */ + getShareCapitalReturnYears(): Observable { + return this.http.get('/shareproducts/capital-return/financial-years'); + } } diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html new file mode 100644 index 000000000..f3c3de609 --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.html @@ -0,0 +1,197 @@ + + +
+
+
+

Return on Members & Share Capital

+ +

+ Membership and share capital as reported to the RBI, per share type — how many members hold + each type, how much capital they have subscribed, and how much of the authorised issue + remains unsold, with the previous financial year alongside for comparison. +

+
+
+ + + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } + + @if (loading) { +
Loading the share register…
+ } @else if (report) { +
+ Reporting period {{ report.periodStartDate }} to + {{ report.periodEndDate }} · compared with FY {{ report.previousFinancialYear }} +
+ + +
+
+ Total Members + {{ totals.membersClosing | number }} + + {{ report.previousFinancialYear }}: {{ totals.membersClosingPrev | number }} + @if (yoyChange(totals.membersClosing, totals.membersClosingPrev) !== null) { + + {{ totals.membersClosing >= totals.membersClosingPrev ? '▲' : '▼' }} + {{ yoyChange(totals.membersClosing, totals.membersClosingPrev) | number: '1.1-1' }}% + + } + +
+
+ Total Share Capital + {{ totals.capitalClosing | number: '1.2-2' }} + + {{ report.previousFinancialYear }}: {{ totals.capitalClosingPrev | number: '1.2-2' }} + @if (yoyChange(totals.capitalClosing, totals.capitalClosingPrev) !== null) { + + {{ totals.capitalClosing >= totals.capitalClosingPrev ? '▲' : '▼' }} + {{ yoyChange(totals.capitalClosing, totals.capitalClosingPrev) | number: '1.1-1' }}% + + } + +
+
+ Shares Allotted + {{ totals.allottedShares | number }} + of {{ totals.authorisedShares | number }} authorised +
+
+ Unissued (Remaining) + {{ totals.unissuedShares | number }} + still available to allot +
+
+ + @for (t of report.shareTypes; track t.productId) { +
+
+ {{ t.shareType }} + + Face value {{ t.unitPrice | number: '1.2-2' }} {{ t.currency }} · Authorised + {{ t.authorisedShares | number }} shares + +
+ + +
+
+
+
+ + {{ t.allottedShares | number }} allotted · + {{ t.unissuedShares | number }} remaining + @if (t.utilisationPercentage != null) { + ({{ t.utilisationPercentage | number: '1.1-1' }}% issued) + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParticularsFY {{ report.financialYear }}FY {{ report.previousFinancialYear }}
Members
Opening{{ t.currentYear.membersOpening | number }}{{ t.previousYear.membersOpening | number }}
New admissions during the year{{ t.currentYear.membersAdmitted | number }}{{ t.previousYear.membersAdmitted | number }}
Ceased during the year{{ t.currentYear.membersCeased | number }}{{ t.previousYear.membersCeased | number }}
Closing members{{ t.currentYear.membersClosing | number }}{{ t.previousYear.membersClosing | number }}
Share Capital
Opening{{ t.currentYear.capitalOpening | number: '1.2-2' }}{{ t.previousYear.capitalOpening | number: '1.2-2' }}
Issued during the year{{ t.currentYear.capitalIssued | number: '1.2-2' }}{{ t.previousYear.capitalIssued | number: '1.2-2' }}
Redeemed / withdrawn{{ t.currentYear.capitalRedeemed | number: '1.2-2' }}{{ t.previousYear.capitalRedeemed | number: '1.2-2' }}
Closing share capital{{ t.currentYear.capitalClosing | number: '1.2-2' }}{{ t.previousYear.capitalClosing | number: '1.2-2' }}
Shares issued during the year{{ t.currentYear.sharesIssued | number }}{{ t.previousYear.sharesIssued | number }}
+
+ } + + @if (!report.shareTypes?.length) { +
No share types are configured, so there is nothing to report.
+ } + } +
diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.scss b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.scss new file mode 100644 index 000000000..37c46bb6d --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.scss @@ -0,0 +1,282 @@ +/** + * 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/. + */ + +.scr-root { + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +.scr-header { + display: flex; + align-items: flex-start; + gap: 20px; + flex-wrap: wrap; + margin-bottom: 20px; + + &__text { + flex: 1 1 420px; + + h2 { + margin: 0 0 6px; + font-size: 22px; + font-weight: 600; + color: #1e293b; + } + + p { + margin: 0; + max-width: 720px; + font-size: 13px; + line-height: 1.55; + color: #64748b; + } + } + + &__actions { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + } +} + +.scr-year { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: #475569; + + select { + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + color: #0f172a; + background: #fff; + } +} + +.scr-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f8fafc; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--primary { + border-color: #1e7f4f; + background: #1e7f4f; + color: #fff; + + &:hover:not(:disabled) { + background: #19693f; + } + } +} + +.scr-period { + margin-bottom: 16px; + font-size: 12.5px; + color: #475569; +} + +.scr-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; + margin-bottom: 22px; +} + +.scr-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 13px 15px; + border: 1px solid #e2e8f0; + border-left: 3px solid #4f46e5; + border-radius: 10px; + background: #fff; + + /* Unissued stock is headroom, not performance — muted so it does not read as a result. */ + &--muted { + border-left-color: #94a3b8; + } + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #64748b; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; + } + + &__sub { + font-size: 11.5px; + color: #64748b; + font-variant-numeric: tabular-nums; + } +} + +.scr-delta { + margin-left: 5px; + font-weight: 700; + + &--up { + color: #15803d; + } + + &--down { + color: #b91c1c; + } +} + +.scr-card { + margin-bottom: 18px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + overflow: hidden; + + &__head { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; + padding: 13px 16px; + border-bottom: 1px solid #eef2f6; + background: #f8fafc; + } + + &__title { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; + } + + &__meta { + font-size: 12px; + color: #64748b; + } +} + +/* Allotted against authorised, as a bar: the remaining headroom is the figure a supervisor checks a + fresh issue against, and a bar shows it without the reader subtracting two numbers. */ +.scr-util { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 12px 16px; + + &__bar { + flex: 1 1 220px; + height: 8px; + border-radius: 9999px; + background: #eef2f6; + overflow: hidden; + } + + &__fill { + height: 100%; + border-radius: 9999px; + background: #4f46e5; + } + + &__text { + font-size: 12.5px; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.scr-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 9px 16px; + text-align: left; + font-size: 10.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + border-bottom: 1px solid #eef2f6; + } + + td { + padding: 8px 16px; + color: #334155; + border-bottom: 1px solid #f4f7fa; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } +} + +/* Members and Share Capital are two separate statements inside one table — labelled so a reader + never has to guess which block a row belongs to. */ +.scr-group td { + padding-top: 13px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #4f46e5; + background: #fbfcfe; +} + +.scr-total td { + font-weight: 700; + color: #0f172a; + background: #fbfcfe; +} + +.scr-note { + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + font-size: 13px; + color: #475569; + + &--error { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; + } +} diff --git a/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts new file mode 100644 index 000000000..a81747021 --- /dev/null +++ b/src/app/rbi-circulars/share-capital-return/share-capital-return.component.ts @@ -0,0 +1,233 @@ +/** + * 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 } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { RbiCircularsService } from '../rbi-circulars.service'; +import { downloadWorkbook } from '../oss-workbook'; + +/** + * Return on Members & Share Capital — the membership and share-capital position reported to the + * RBI, broken down by share type, with the previous year alongside. + * + * A supervisor reading this asks three things of every share type the society offers: how many + * members hold it, how much capital they have put in, and how much of the authorised issue is still + * unsold. Movement matters as much as position — a society whose membership fell while its capital + * rose has done something quite different from one where both grew — so each figure is shown as + * opening, movement, and closing, against the same three for the year before. + * + * Every figure comes from the share register itself rather than a stored summary, so the return + * cannot drift from the accounts it describes. + */ +@Component({ + selector: 'mifosx-share-capital-return', + standalone: true, + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ], + templateUrl: './share-capital-return.component.html', + styleUrls: ['./share-capital-return.component.scss'] +}) +export class ShareCapitalReturnComponent implements OnInit { + private service = inject(RbiCircularsService); + + years: { startYear: number; label: string }[] = []; + selectedYear: number | null = null; + + report: any = null; + loading = false; + loadError: string | null = null; + exporting = false; + + ngOnInit(): void { + this.service + .getShareCapitalReturnYears() + .pipe(catchError(() => of([]))) + .subscribe((res: any[]) => { + this.years = res ?? []; + // Opens on the most recent year rather than an empty screen: that is the return being + // prepared almost every time this page is reached. + this.selectedYear = this.years.length ? this.years[0].startYear : null; + this.load(); + }); + } + + load(): void { + if (this.selectedYear == null) { + return; + } + this.loading = true; + this.loadError = null; + this.service + .getShareCapitalReturn(this.selectedYear) + .pipe( + catchError((err: any) => { + this.loadError = err?.error?.defaultUserMessage ?? 'Could not load the share capital position.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.report = res; + this.loading = false; + }); + } + + onYearChange(startYear: any): void { + this.selectedYear = Number(startYear); + this.load(); + } + + /** Net movement in members over the year — admitted less ceased. */ + netMembers(position: any): number { + return (Number(position?.membersAdmitted) || 0) - (Number(position?.membersCeased) || 0); + } + + /** Net movement in capital — issued less redeemed. */ + netCapital(position: any): number { + return (Number(position?.capitalIssued) || 0) - (Number(position?.capitalRedeemed) || 0); + } + + /** + * Change against the prior year, as a percentage. + * + * Null rather than zero when the prior year was nil: a society going from no members to fifty has + * not grown by 0%, and showing a number there would be worse than showing nothing. + */ + yoyChange(current: any, previous: any): number | null { + const prev = Number(previous) || 0; + const curr = Number(current) || 0; + if (prev === 0) { + return null; + } + return ((curr - prev) / prev) * 100; + } + + /** Society-wide totals, so the return has a bottom line and not only per-type rows. */ + get totals(): any { + const types = this.report?.shareTypes ?? []; + const sum = (pick: (t: any) => number) => types.reduce((s: number, t: any) => s + (pick(t) || 0), 0); + return { + membersClosing: sum((t) => Number(t.currentYear?.membersClosing)), + membersClosingPrev: sum((t) => Number(t.previousYear?.membersClosing)), + capitalClosing: sum((t) => Number(t.currentYear?.capitalClosing)), + capitalClosingPrev: sum((t) => Number(t.previousYear?.capitalClosing)), + authorisedShares: sum((t) => Number(t.authorisedShares)), + allottedShares: sum((t) => Number(t.allottedShares)), + unissuedShares: sum((t) => Number(t.unissuedShares)) + }; + } + + /** + * The return as an Excel workbook, laid out the way it is read on screen. + * + * One row per share type with both years side by side, rather than a sheet per year: the whole + * point of the return is the comparison, and splitting it would leave the reader to do the + * matching by hand. + */ + async exportToExcel(): Promise { + if (!this.report) { + return; + } + this.exporting = true; + try { + const ExcelJS = await import('exceljs'); + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('Members & Share Capital'); + const fy = this.report.financialYear; + const prev = this.report.previousFinancialYear; + + ws.mergeCells('A1:L1'); + ws.getCell('A1').value = 'RETURN ON MEMBERS AND SHARE CAPITAL'; + ws.getCell('A1').font = { bold: true, size: 14 }; + ws.getCell('A1').alignment = { horizontal: 'center' }; + + ws.mergeCells('A2:L2'); + ws.getCell('A2').value = `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`; + ws.getCell('A2').alignment = { horizontal: 'center' }; + + const headers = [ + 'Share Type', + 'Authorised Shares', + 'Allotted Shares', + 'Unissued (Remaining)', + `Members Opening (${fy})`, + 'New Admissions', + 'Ceased', + `Members Closing (${fy})`, + `Members Closing (${prev})`, + `Capital Opening (${fy})`, + 'Capital Issued', + `Capital Closing (${fy})`, + `Capital Closing (${prev})` + ]; + const headerRow = ws.getRow(4); + headers.forEach((h, i) => { + headerRow.getCell(i + 1).value = h; + headerRow.getCell(i + 1).font = { bold: true }; + headerRow.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } }; + headerRow.getCell(i + 1).alignment = { wrapText: true, vertical: 'middle' }; + }); + + (this.report.shareTypes ?? []).forEach((t: any, i: number) => { + const row = ws.getRow(5 + i); + const values = [ + t.shareType, + Number(t.authorisedShares) || 0, + Number(t.allottedShares) || 0, + Number(t.unissuedShares) || 0, + Number(t.currentYear?.membersOpening) || 0, + Number(t.currentYear?.membersAdmitted) || 0, + Number(t.currentYear?.membersCeased) || 0, + Number(t.currentYear?.membersClosing) || 0, + Number(t.previousYear?.membersClosing) || 0, + Number(t.currentYear?.capitalOpening) || 0, + Number(t.currentYear?.capitalIssued) || 0, + Number(t.currentYear?.capitalClosing) || 0, + Number(t.previousYear?.capitalClosing) || 0 + ]; + values.forEach((v, c) => { + row.getCell(c + 1).value = v; + if (c >= 9) { + row.getCell(c + 1).numFmt = '#,##0.00'; + } + }); + }); + + const totalRow = ws.getRow(5 + (this.report.shareTypes?.length ?? 0)); + const t = this.totals; + totalRow.getCell(1).value = 'TOTAL'; + totalRow.getCell(2).value = t.authorisedShares; + totalRow.getCell(3).value = t.allottedShares; + totalRow.getCell(4).value = t.unissuedShares; + totalRow.getCell(8).value = t.membersClosing; + totalRow.getCell(9).value = t.membersClosingPrev; + totalRow.getCell(12).value = t.capitalClosing; + totalRow.getCell(13).value = t.capitalClosingPrev; + [12, 13].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00')); + for (let c = 1; c <= 13; c++) { + totalRow.getCell(c).font = { bold: true }; + } + + ws.getColumn(1).width = 26; + for (let c = 2; c <= 13; c++) { + ws.getColumn(c).width = 16; + } + + await downloadWorkbook(wb as any, `Return on Members and Share Capital - FY ${fy}.xlsx`); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts b/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts new file mode 100644 index 000000000..c73fbb6ec --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest-routing.module.ts @@ -0,0 +1,29 @@ +/** + * 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 { SavingsDailyInterestComponent } from './savings-daily-interest.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'savings-daily-interest', + component: SavingsDailyInterestComponent, + data: { title: 'Savings Daily Interest', breadcrumb: 'Savings Daily Interest' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class SavingsDailyInterestRoutingModule {} diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.html b/src/app/savings-daily-interest/savings-daily-interest.component.html new file mode 100644 index 000000000..d52213206 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.html @@ -0,0 +1,216 @@ + + +
+
+

Savings Daily Interest

+
+ + + +
+
+ + @if (message) { +
+ {{ message }} +
+ } + +
+ + @if (selectedProduct) { + + } + @if (selectedAccount) { + + } + +
+ + @if (loading) { +
Loading…
+ } + + + @if (activeTab === 'products' && !loading) { + + + + + + + + + + + + + + + @for (product of products; track product.id) { + + + + + + + + + + + } @empty { + + } + +
ProductRatePostsAccountsAccrued This CycleLast PostedNext PostingStatus
+ {{ product.name }} + {{ product.short_name }} + {{ product.rate }}%{{ postingPeriodLabel(product.posting_period_enum) }}{{ product.active_accounts }}{{ product.accrued_this_cycle | number: '1.2-2' }}{{ formatDate(product.last_posting_date) }}{{ formatDate(product.next_posting_date) }} + @if (product.last_run_status) { + + {{ product.last_run_status }} + + } @else { + Not run + } +
No savings products carry a ledger yet.
+ } + + + @if (activeTab === 'accounts' && !loading) { + + + + + + + + + + + + + + + + @for (account of accounts; track account.savings_account_id) { + + + + + + + + + + + + } @empty { + + } + +
AccountClientAs AtOpeningClosingDay InterestSum This CycleCyclePays On
{{ account.account_no }}{{ account.client_name || '—' }}{{ formatDate(account.balance_date) }}{{ account.opening_balance | number: '1.2-2' }}{{ account.closing_balance | number: '1.2-2' }} + {{ account.daily_interest | number: '1.4-4' }} + @if (account.below_min_balance) { + below min + } + {{ account.cycle_cumulative_interest | number: '1.2-2' }}{{ account.cycle_number }}{{ formatDate(account.cycle_end_date) }}
No ledger rows for this product yet.
+ } + + + @if (activeTab === 'ledger' && !loading) { + + + + + + + + + + + + + + @for (day of ledger; track day.balance_date) { + + + + + + + + + + } @empty { + + } + +
DateOpeningClosingDay InterestRunning SumCyclePosted
+ {{ formatDate(day.balance_date) }} + @if (day.is_posting_day) { + + } + {{ day.opening_balance | number: '1.2-2' }}{{ day.closing_balance | number: '1.2-2' }} + {{ day.daily_interest | number: '1.4-4' }} + @if (day.below_min_balance) { + below min + } + {{ day.cycle_cumulative_interest | number: '1.4-4' }}{{ day.cycle_number }} + @if (day.posted_amount !== null) { + {{ day.posted_amount | number: '1.2-2' }} + } @else { + — + } +
No days recorded for this account.
+ } + + + @if (activeTab === 'runs' && !loading) { + + + + + + + + + + + + + + @for (run of runs; track run.id) { + + + + + + + + + + } @empty { + + } + +
ProductPosting DateAccountsPooledDistributedResidualStatus
{{ run.product_name }}{{ formatDate(run.posting_date) }}{{ run.account_count }}{{ run.pooled_amount | number: '1.2-2' }}{{ run.distributed_amount | number: '1.2-2' }}{{ run.rounding_residual | number: '1.2-2' }} + {{ run.status }} + @if (run.failure_reason) { +
{{ run.failure_reason }}
+ } +
No posting runs yet.
+ } +
diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.scss b/src/app/savings-daily-interest/savings-daily-interest.component.scss new file mode 100644 index 000000000..7b5ea3935 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.scss @@ -0,0 +1,173 @@ +/** + * 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/. + */ + +.sdi-container { + padding: 1rem; +} + +.sdi-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + } +} + +.sdi-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.sdi-message { + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; + border-radius: 4px; + cursor: pointer; + background: #e8f4ea; + border-left: 4px solid #2e7d32; + font-size: 0.9rem; + + &.error { + background: #fdecea; + border-left-color: #c62828; + } +} + +.sdi-tabs { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + margin-bottom: 1rem; + + button { + background: none; + border: none; + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.9rem; + border-bottom: 2px solid transparent; + + &.active { + border-bottom-color: #1565c0; + font-weight: 600; + } + } +} + +.sdi-loading { + padding: 2rem; + text-align: center; + opacity: 0.7; +} + +// Wide tables scroll inside their own box rather than pushing the page sideways. +.sdi-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + display: block; + overflow-x: auto; + white-space: nowrap; + + th, + td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + } + + th { + font-weight: 600; + background: rgba(0, 0, 0, 0.03); + position: sticky; + top: 0; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tr.clickable { + cursor: pointer; + + &:hover { + background: rgba(21, 101, 192, 0.06); + } + } + + // The day the cycle is paid out and the sum goes back to zero. + tr.posting-row { + background: rgba(46, 125, 50, 0.08); + font-weight: 600; + } +} + +.accent { + color: #1565c0; + font-weight: 600; +} + +// A day that earned nothing because the balance sat under the product minimum. +.zero { + opacity: 0.55; +} + +.sdi-sub { + display: block; + font-size: 0.75rem; + opacity: 0.65; +} + +.sdi-empty { + text-align: center; + padding: 2rem; + opacity: 0.6; +} + +.sdi-chip { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-size: 0.75rem; + background: rgba(0, 0, 0, 0.08); + + &.ok { + background: rgba(46, 125, 50, 0.15); + color: #2e7d32; + } + + &.bad { + background: rgba(198, 40, 40, 0.15); + color: #c62828; + } +} + +.sdi-flag { + display: inline-block; + margin-left: 0.4rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 500; + background: rgba(0, 0, 0, 0.08); + + &.paid { + background: rgba(46, 125, 50, 0.2); + color: #2e7d32; + } +} diff --git a/src/app/savings-daily-interest/savings-daily-interest.component.ts b/src/app/savings-daily-interest/savings-daily-interest.component.ts new file mode 100644 index 000000000..829f95423 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.component.ts @@ -0,0 +1,213 @@ +/** + * 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 { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + SavingsDailyInterestService, + SavingsInterestAccount, + SavingsInterestDay, + SavingsInterestProduct, + SavingsInterestRun +} from './savings-daily-interest.service'; + +/** + * The savings daily interest ledger. + * + * Interest has always been a single number on the account screen with nothing behind it. This + * shows the working: pick a product, see its accounts and where each stands in its cycle, then + * open one account and read it day by day - what the balance was, what that day earned, and the + * running total climbing until the posting day pays it out and resets it to zero. + */ +@Component({ + selector: 'mifosx-savings-daily-interest', + templateUrl: './savings-daily-interest.component.html', + styleUrls: ['./savings-daily-interest.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class SavingsDailyInterestComponent implements OnInit { + private service = inject(SavingsDailyInterestService); + + activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products'; + + products: SavingsInterestProduct[] = []; + accounts: SavingsInterestAccount[] = []; + ledger: SavingsInterestDay[] = []; + runs: SavingsInterestRun[] = []; + + selectedProduct: SavingsInterestProduct | null = null; + selectedAccount: SavingsInterestAccount | null = null; + + loading = false; + message = ''; + messageIsError = false; + + ngOnInit(): void { + this.loadProducts(); + } + + // -------------------- loading -------------------- + + loadProducts(): void { + this.loading = true; + this.service.getProducts().subscribe({ + next: (data) => { + this.products = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + openProduct(product: SavingsInterestProduct): void { + this.selectedProduct = product; + this.selectedAccount = null; + this.activeTab = 'accounts'; + this.loading = true; + this.service.getAccountsForProduct(product.id).subscribe({ + next: (data) => { + this.accounts = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + openAccount(account: SavingsInterestAccount): void { + this.selectedAccount = account; + this.activeTab = 'ledger'; + this.loading = true; + this.service.getAccountLedger(account.savings_account_id).subscribe({ + next: (data) => { + this.ledger = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + loadRuns(): void { + this.activeTab = 'runs'; + this.loading = true; + this.service.getRuns().subscribe({ + next: (data) => { + this.runs = data ?? []; + this.loading = false; + }, + error: (err) => this.fail(err) + }); + } + + // -------------------- actions -------------------- + + /** + * The chain check and the unposted-total check. Nothing found is the answer you want: every day + * picks up where the last one left off, and the ledger still agrees with the accounts. + */ + runReconciliation(): void { + this.loading = true; + this.service.reconcile().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = !result.clean; + this.message = result.clean + ? 'Reconciliation clean: the balance chain is unbroken and every cycle agrees with its account.' + : `${result.problemCount} problem(s) found: ${result.problems.slice(0, 3).join(' | ')}`; + }, + error: (err) => this.fail(err) + }); + } + + runToday(): void { + this.loading = true; + this.service.runForDate().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = false; + this.message = `Run completed for ${result?.ranFor ?? 'today'}.`; + this.loadProducts(); + }, + error: (err) => this.fail(err) + }); + } + + runBackfill(): void { + this.loading = true; + this.service.backfill().subscribe({ + next: (result) => { + this.loading = false; + this.messageIsError = false; + this.message = `Backfill wrote ${result?.rowsWritten ?? 0} rows up to ${result?.upTo ?? 'today'}.`; + this.loadProducts(); + }, + error: (err) => this.fail(err) + }); + } + + // -------------------- display helpers -------------------- + + /** Fineract returns dates as [year, month, day]; month is 1-based. */ + formatDate(value: number[] | null | undefined): string { + if (!value || value.length < 3) { + return '—'; + } + const [year, month, day] = value; + return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`; + } + + postingPeriodLabel(value: number): string { + switch (value) { + case 1: + return 'Daily'; + case 4: + return 'Monthly'; + case 5: + return 'Quarterly'; + case 6: + return 'Half-Yearly'; + case 7: + return 'Annual'; + default: + return 'Not set'; + } + } + + /** + * Accounts can carry a posting period of their own that differs from the product they were + * opened under, and the engine follows the account. Worth showing rather than hiding: a + * quarterly product whose accounts all post daily is almost never what was intended. + */ + productPostingMismatch(product: SavingsInterestProduct): boolean { + return this.accounts.length > 0 && this.selectedProduct?.id === product.id; + } + + backToProducts(): void { + this.activeTab = 'products'; + this.selectedProduct = null; + this.selectedAccount = null; + } + + backToAccounts(): void { + this.activeTab = 'accounts'; + this.selectedAccount = null; + } + + dismissMessage(): void { + this.message = ''; + } + + private fail(error: any): void { + this.loading = false; + this.messageIsError = true; + this.message = error?.error?.defaultUserMessage ?? error?.message ?? 'Request failed.'; + } +} diff --git a/src/app/savings-daily-interest/savings-daily-interest.module.ts b/src/app/savings-daily-interest/savings-daily-interest.module.ts new file mode 100644 index 000000000..8092f409e --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.module.ts @@ -0,0 +1,22 @@ +/** + * 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 { SharedModule } from '../shared/shared.module'; +import { SavingsDailyInterestRoutingModule } from './savings-daily-interest-routing.module'; +import { SavingsDailyInterestComponent } from './savings-daily-interest.component'; + +@NgModule({ + imports: [ + SharedModule, + SavingsDailyInterestRoutingModule, + SavingsDailyInterestComponent + ] +}) +export class SavingsDailyInterestModule {} diff --git a/src/app/savings-daily-interest/savings-daily-interest.service.ts b/src/app/savings-daily-interest/savings-daily-interest.service.ts new file mode 100644 index 000000000..77dc5d8d0 --- /dev/null +++ b/src/app/savings-daily-interest/savings-daily-interest.service.ts @@ -0,0 +1,137 @@ +/** + * 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, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** A savings product and where it stands in its interest posting timetable. */ +export interface SavingsInterestProduct { + id: number; + name: string; + short_name: string; + rate: number; + posting_period_enum: number; + run_at_time: string; + last_posting_date: number[] | null; + next_posting_date: number[] | null; + last_run_status: string | null; + is_active: boolean; + active_accounts: number; + accrued_this_cycle: number; +} + +/** One account's position in the product ledger, as at the latest day recorded. */ +export interface SavingsInterestAccount { + savings_account_id: number; + account_no: string; + client_name: string; + balance_date: number[]; + opening_balance: number; + closing_balance: number; + daily_interest: number; + cycle_cumulative_interest: number; + cycle_number: number; + cycle_start_date: number[]; + cycle_end_date: number[]; + below_min_balance: boolean; +} + +/** One day of one account: the working behind that day's interest. */ +export interface SavingsInterestDay { + balance_date: number[]; + opening_balance: number; + closing_balance: number; + daily_interest: number; + cycle_cumulative_interest: number; + cycle_number: number; + cycle_start_date: number[]; + cycle_end_date: number[]; + is_posting_day: boolean; + posted_amount: number | null; + savings_transaction_id: number | null; + below_min_balance: boolean; + interest_rate: number; + days_in_year: number; + min_balance_for_interest: number | null; + last_recomputed_on: number[] | null; +} + +/** What a posting run pooled and paid, and whether it finished. */ +export interface SavingsInterestRun { + id: number; + savings_product_id: number; + product_name: string; + posting_date: number[]; + account_count: number; + pooled_amount: number; + distributed_amount: number; + rounding_residual: number; + status: string; + failure_reason: string | null; + started_at: number[] | null; + completed_at: number[] | null; +} + +export interface SavingsInterestReconciliation { + clean: boolean; + problemCount: number; + problems: string[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class SavingsDailyInterestService { + private http = inject(HttpClient); + + private readonly base = '/savingsdailyinterest'; + + getProducts(): Observable { + return this.http.get(`${this.base}/products`); + } + + getAccountsForProduct(productId: number): Observable { + return this.http.get(`${this.base}/products/${productId}/accounts`); + } + + getAccountLedger(accountId: number, limit = 400): Observable { + const params = new HttpParams().set('limit', limit); + return this.http.get(`${this.base}/accounts/${accountId}`, { params }); + } + + getRuns(productId?: number): Observable { + let params = new HttpParams(); + if (productId) { + params = params.set('productId', productId); + } + return this.http.get(`${this.base}/runs`, { params }); + } + + reconcile(): Observable { + return this.http.get(`${this.base}/reconcile`); + } + + /** Runs a day by hand: accrual for every account, then any product whose cycle ends that day. */ + runForDate(date?: string): Observable { + let params = new HttpParams(); + if (date) { + params = params.set('date', date); + } + return this.http.post(`${this.base}/run`, {}, { params }); + } + + /** Rebuilds the ledger for every account from activation up to the given date. */ + backfill(upTo?: string): Observable { + let params = new HttpParams(); + if (upTo) { + params = params.set('upTo', upTo); + } + return this.http.post(`${this.base}/backfill`, {}, { params }); + } +} diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html index da5657779..e1a192400 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.html @@ -14,6 +14,26 @@
+ + @if (dividendContext) { +
+
+ Issuing a dividend Demand Draft + + {{ dividendContext.beneficiary }} · + {{ dividendContext.amount | number: '1.2-2' }} + @if (dividendContext.financialYear) { + · FY {{ dividendContext.financialYear }} + } + + Open a series below and choose an available leaflet. +
+ +
+ } + @@ -80,8 +100,15 @@ diff --git a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts index edd2f06f9..c1151416e 100644 --- a/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts +++ b/src/app/sponsor-dd/dd-inventory-list/dd-inventory-list.component.ts @@ -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 { + 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) => { diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts index 2354fda34..ddfc1d72a 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts @@ -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'; } diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html index 1fe03e4c3..25bd2f873 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.html +++ b/src/app/sponsor-dd/dd-list/dd-list.component.html @@ -19,6 +19,20 @@ + +
+
+ Issuing a dividend Demand Draft + + {{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }} + · FY {{ dividendContext.financialYear }} + + Pick an available leaflet below and press Issue. +
+ +
+ Search by DD Leaflet No. diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts index 01bf708a3..18e2b27a8 100644 --- a/src/app/sponsor-dd/dd-list/dd-list.component.ts +++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts @@ -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: ` -

Issue Demand Draft (DD No: {{ data.leaflet.ddNumber }})

+

+ {{ dividend ? 'Pay Dividend by Demand Draft' : 'Issue Demand Draft' }} (DD No: + {{ data.leaflet.ddNumber }}) +

-

- 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. -

+ + +
+ Settling a dividend + + {{ dividend.beneficiary }} · {{ dividend.amount | number: '1.2-2' }} + · FY {{ dividend.financialYear }} + + + Funded from Dividend Payable — no charge, no GST, nothing collected from the member. The draft is drawn + once a manager approves the claim. + +
+
+ +

+ 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. +

+
- + How is the customer paying? Debit from their Savings/Current Account @@ -382,7 +482,7 @@ export class DdListComponent implements OnInit, AfterViewInit { - + Account Type @@ -410,7 +510,7 @@ export class DdListComponent implements OnInit, AfterViewInit {

- +
Cheque Number @@ -426,7 +526,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
- +

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 { Client (Customer) ID @@ -537,13 +637,14 @@ export class DdListComponent implements OnInit, AfterViewInit { DD Face Value (₹) - + Locked — the member's unclaimed dividend for this lock. + Locked — from the DD Charges & GST breakdown entered at Transaction Entry. Face Value is required - + Charges / Commission (₹) @@ -560,8 +661,8 @@ export class DdListComponent implements OnInit, AfterViewInit {

@@ -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' }} `, 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); 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( diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html index bc22d9639..c4f7c1c2a 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html @@ -34,20 +34,23 @@

} @else { + @for (row of rows; track row.dividendId) { - +
Actions - No dividend declarations are waiting for approval.
{{ row.productName }} {{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }} {{ row.amount | number: '1.2-2' }} {{ row.memberCount }} {{ row.declaredOn | slice: 0 : 10 }} - + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss index 75905888b..c8b7cf2a7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss @@ -83,3 +83,12 @@ animation: none; } } + +/* The whole row opens the member-by-member breakdown, so it has to look like it will. */ +.dr-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts index b7968b4e2..55fdfe891 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; @@ -38,6 +39,8 @@ import { ViewDividendComponent } from '../../../products/share-products/view-div export class DividendRequestsComponent implements OnInit { private service = inject(PendingDividendsService); private productsService = inject(ProductsService); + private route = inject(ActivatedRoute); + private router = inject(Router); rows: any[] = []; loading = false; @@ -51,9 +54,30 @@ export class DividendRequestsComponent implements OnInit { viewDividendId: number | null = null; ngOnInit(): void { + this.openRequestedDeclaration(); this.load(); } + /** + * Opens the breakdown straight away when the officer arrived here from a specific declaration. + * + * The Dividends list sends them here to approve one declaration in particular, so making them + * find its row again in a list of every pending declaration would undo the point of the link. + * Read from the query string rather than a service so a refreshed or shared URL still opens the + * same declaration. + */ + private openRequestedDeclaration(): void { + const params = this.route.snapshot.queryParams; + const productId = Number(params['productId']); + const dividendId = Number(params['dividendId']); + if (!productId || !dividendId) { + return; + } + this.viewProductId = productId; + this.viewDividendId = dividendId; + this.showViewModal = true; + } + openView(row: any): void { this.viewProductId = row.productId; this.viewDividendId = row.dividendId; @@ -64,6 +88,11 @@ export class DividendRequestsComponent implements OnInit { this.showViewModal = false; this.viewProductId = null; this.viewDividendId = null; + // Dropped from the URL as well, or the declaration the officer just closed would reopen on the + // next refresh and there would be no way to get back to the plain list. + if (this.route.snapshot.queryParams['dividendId']) { + this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true }); + } } onViewActionCompleted(): void { diff --git a/src/main.scss b/src/main.scss index 96e8e3a65..aa0387a44 100644 --- a/src/main.scss +++ b/src/main.scss @@ -97,11 +97,12 @@ } .letter-mega-panel { - max-width: 850px !important; + max-width: 1100px !important; } .letter-grid { - grid-template-columns: repeat(4, 1fr) !important; + grid-template-columns: repeat(5, minmax(190px, 1fr)) !important; + min-width: 1040px; } .reconciliation-mega-panel {