From c7428d0cb26f9957678736545762decfa4210f28 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 13:06:02 +0530 Subject: [PATCH] feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes - Add the full Share Reports surface (final balance, account statement, issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry, payment certificate, share detail, dividend statement, folio statement, distinctive numbers, area-wise, mailing labels/letters, share transfer) as standalone routed components, wired into the Share menu - Wire the unattributed Share Collection receipt list into Apply Collection and Apply Additional Shares, with an Attach action independent of account/client - Sync the GL Account and Voucher No fields to whichever receipt is ticked or found, and surface the receipt's real Dr/Cr GL entries in its detail popup - Block Apply Additional Shares until the account's original application is settled Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier.service.ts | 11 + src/app/core/shell/shell.component.html | 42 +- .../share-account-statement.component.html | 209 ++++ .../share-account-statement.component.ts | 238 ++++ .../share-area-wise.component.html | 174 +++ .../share-area-wise.component.ts | 188 +++ .../share-detail-report.component.html | 192 +++ .../share-detail-report.component.ts | 293 +++++ .../share-distinctive-numbers.component.html | 152 +++ .../share-distinctive-numbers.component.ts | 206 +++ .../share-dividend-statement.component.html | 171 +++ .../share-dividend-statement.component.ts | 151 +++ .../share-enquiry.component.html | 291 +++++ .../share-enquiry/share-enquiry.component.ts | 246 ++++ .../share-final-balance.component.html | 208 ++++ .../share-final-balance.component.ts | 279 +++++ .../share-folio-statement.component.html | 250 ++++ .../share-folio-statement.component.ts | 217 ++++ .../share-guarantor-report.component.html | 162 +++ .../share-guarantor-report.component.ts | 208 ++++ .../share-issued-report.component.html | 204 +++ .../share-issued-report.component.ts | 264 ++++ .../share-label/share-label.component.html | 124 ++ .../share-label/share-label.component.ts | 197 +++ .../share-letter/share-letter.component.html | 171 +++ .../share-letter/share-letter.component.ts | 227 ++++ ...hare-member-interest-report.component.html | 166 +++ .../share-member-interest-report.component.ts | 197 +++ .../share-nominal-report.component.html | 244 ++++ .../share-nominal-report.component.ts | 337 +++++ .../share-payment-certificate.component.html | 160 +++ .../share-payment-certificate.component.ts | 236 ++++ .../shares/reports/share-report.shared.scss | 494 ++++++++ src/app/shares/reports/share-report.util.ts | 185 +++ .../share-status-report.component.html | 230 ++++ .../share-status-report.component.ts | 268 ++++ .../share-transfer.component.html | 282 +++++ .../share-transfer.component.ts | 339 +++++ .../share-wise-report.component.html | 165 +++ .../share-wise-report.component.ts | 167 +++ .../apply-shares/apply-shares.component.html | 64 +- .../apply-shares/apply-shares.component.scss | 63 + .../apply-shares/apply-shares.component.ts | 125 +- .../apply-collection-dialog.component.html | 748 ++++++++--- .../apply-collection-dialog.component.scss | 1099 ++++++++++++++--- .../apply-collection-dialog.component.ts | 178 ++- .../shares-account-view.component.ts | 8 +- src/app/shares/shares-routing.module.ts | 133 ++ src/app/shares/shares.service.ts | 172 +++ src/environments/.env.ts | 4 +- src/main.scss | 11 + 51 files changed, 10721 insertions(+), 429 deletions(-) create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.html create mode 100644 src/app/shares/reports/share-account-statement/share-account-statement.component.ts create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.html create mode 100644 src/app/shares/reports/share-area-wise/share-area-wise.component.ts create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.html create mode 100644 src/app/shares/reports/share-detail-report/share-detail-report.component.ts create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html create mode 100644 src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html create mode 100644 src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.html create mode 100644 src/app/shares/reports/share-enquiry/share-enquiry.component.ts create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.html create mode 100644 src/app/shares/reports/share-final-balance/share-final-balance.component.ts create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.html create mode 100644 src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html create mode 100644 src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.html create mode 100644 src/app/shares/reports/share-issued-report/share-issued-report.component.ts create mode 100644 src/app/shares/reports/share-label/share-label.component.html create mode 100644 src/app/shares/reports/share-label/share-label.component.ts create mode 100644 src/app/shares/reports/share-letter/share-letter.component.html create mode 100644 src/app/shares/reports/share-letter/share-letter.component.ts create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html create mode 100644 src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.html create mode 100644 src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html create mode 100644 src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts create mode 100644 src/app/shares/reports/share-report.shared.scss create mode 100644 src/app/shares/reports/share-report.util.ts create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.html create mode 100644 src/app/shares/reports/share-status-report/share-status-report.component.ts create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.html create mode 100644 src/app/shares/reports/share-transfer/share-transfer.component.ts create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.html create mode 100644 src/app/shares/reports/share-wise-report/share-wise-report.component.ts diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 83d3ba55b..bab2c1ff7 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -390,6 +390,17 @@ export class CashierService { return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); } + /** + * Approved share-collection receipts that name no member yet. + * + * A receipt taken straight into a GL account carries no member of its own, so it is never + * attributed automatically. This lists them so the counter can see the voucher they just raised + * and attach it deliberately, instead of being told there is nothing to apply. + */ + getUnattributedShareCollections(): Observable { + return this.http.get('/shareadvancecollection/unattributed'); + } + /** Receipts still sitting in Share Sundry, optionally for a single member. */ getUnappliedShareAdvances(clientId?: number): Observable { const suffix = clientId ? `?clientId=${clientId}` : ''; diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7d8c63ad2..aa9516dbe 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1300,72 +1300,76 @@
Share Operations
- - - - -
Share Documents
- - - - -
Share Reports
- - - -
Share Admin
- - - - + + -
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.html b/src/app/shares/reports/share-account-statement/share-account-statement.component.html new file mode 100644 index 000000000..8e2f1d259 --- /dev/null +++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.html @@ -0,0 +1,209 @@ + + +
+
+
+ +
+

Share Account Statement

+
+ One member's share account, movement by movement, with the holding each movement left behind. +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ @if (accountOptions.length && !selectedAccountId) { +
+ @for (opt of accountOptions; track opt.id) { + + } +
+ } +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + @if (!selectedAccountId) { +
Search for a member or share account number above to open its statement.
+ } @else if (loading) { +
Loading statement…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (account) { + +
+
+
+
Member
+
{{ account.clientName }}
+
+
+
Membership No
+
{{ account.cifNumber || '—' }}
+
+
+
Share A/c No
+
{{ account.accountNo }}
+
+
+
Product
+
{{ account.productName }}
+
+
+
Branch
+
{{ account.officeName || '—' }}
+
+
+
Status
+
+ {{ + statusLabel(account.statusEnum) + }} +
+
+
+
Linked Savings
+
{{ account.linkedSavingsAccountNo || 'Not linked' }}
+
+
+
Activated
+
{{ account.activatedDate || '—' }}
+
+
+ +
+ + @if (totals) { +
+
+ +
+
{{ totals.closingShares | number }}
+
Closing Shares
+
+
+
+ +
+
₹{{ totals.closingValue | number: '1.2-2' }}
+
Closing Value
+
+
+
+ +
+
{{ totals.sharesIn | number }}
+
Shares Purchased
+
+
+
+ +
+
{{ totals.sharesOut | number }}
+
Shares Redeemed
+
+
+
+ } + + @if (!lines.length) { +
+ No settled movement on this account{{ fromDate || toDate ? ' in the selected period' : '' }}. +
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (line of lines; track line.transactionId) { + + + + + + + + + + + + + } + + + + + + + + + + + + +
DateParticularsBoard ResolutionFunded ByShares InShares OutUnit PriceAmountBalance SharesBalance Value
{{ line.transactionDate }} + {{ typeLabel(line.typeEnum) }} + {{ line.resolutionNumber || '—' }}{{ funding(line.fundingSource) }}{{ line.sharesIn ? (line.sharesIn | number) : '' }}{{ line.sharesOut ? (line.sharesOut | number) : '' }}{{ line.unitPrice | number: '1.2-2' }}{{ line.amount | number: '1.2-2' }}{{ line.balanceShares | number }}₹{{ line.balanceValue | number: '1.2-2' }}
Closing balance{{ totals?.sharesIn | number }}{{ totals?.sharesOut | number }}{{ totals?.closingShares | number }}₹{{ totals?.closingValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.ts b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts new file mode 100644 index 000000000..2272c7595 --- /dev/null +++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts @@ -0,0 +1,238 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_ACCOUNT_STATUS, + SHARE_TXN_TYPE, + fundingLabel, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface StatementLine { + transactionId: number; + transactionDate: string; + typeEnum: number; + statusEnum: number; + sharesIn: number; + sharesOut: number; + unitPrice: number; + amount: number; + chargeAmount: number; + amountPaid: number; + resolutionNumber: string; + meetingDate: string; + fundingSource: string; + balanceShares: number; + balanceValue: number; +} + +/** + * Share Account Statement — one member's share account, movement by movement, with the holding each + * movement left behind. + * + *

+ * The account is chosen first because a statement is always about one account; the search box is + * server-side (the same summary endpoint the Share Holder report uses), so it finds a member across + * every branch rather than only among accounts already downloaded. + * + *

+ * A period narrows which lines are shown but never how the running balance was reached — the server + * walks the account's whole life and filters afterwards, so a mid-year statement opens at the + * holding the member actually had, not at zero. + */ +@Component({ + selector: 'mifosx-share-account-statement', + templateUrl: './share-account-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareAccountStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + /** Account picker. */ + accountSearch = ''; + accountOptions: any[] = []; + searchingAccounts = false; + selectedAccountId: number | null = null; + + /** Statement. */ + loading = false; + loadError = ''; + account: any = null; + lines: StatementLine[] = []; + totals: any = null; + + fromDate = ''; + toDate = ''; + + private searchTerm$ = new Subject(); + + ngOnInit(): void { + this.searchTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => { + this.searchingAccounts = true; + return this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))); + }) + ) + .subscribe((res: any) => { + this.accountOptions = Array.isArray(res) ? res : res?.pageItems || []; + this.searchingAccounts = false; + }); + + // Deep-linkable: the account view's own statement button, and any report that drills in here, + // can hand over ?shareAccountId= and land on a loaded statement rather than an empty picker. + const preselected = this.route.snapshot.queryParamMap.get('shareAccountId'); + if (preselected) { + this.selectedAccountId = Number(preselected); + this.load(); + } + this.searchTerm$.next(''); + } + + onAccountSearch(term: string): void { + this.accountSearch = term; + this.searchTerm$.next(term.trim()); + } + + selectAccount(option: any): void { + this.selectedAccountId = option.id; + this.accountOptions = []; + this.accountSearch = `${option.clientName} — ${option.accountNo}`; + this.load(); + } + + load(): void { + if (!this.selectedAccountId) { + return; + } + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareAccountStatement(this.selectedAccountId, { fromDate: this.fromDate, toDate: this.toDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share account statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.account = res?.account || null; + this.lines = res?.lines || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + clearPeriod(): void { + this.fromDate = ''; + this.toDate = ''; + this.load(); + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + openAccount(): void { + if (this.account) { + this.router.navigate([ + '/clients', + this.account.clientId, + 'shares-accounts', + this.account.shareAccountId, + 'general' + ]); + } + } + + exportCsv(): void { + if (!this.account) { + return; + } + shareReportCsv( + `share-statement-${this.account.accountNo}`, + [ + 'Date', + 'Particulars', + 'Board Resolution', + 'Funded By', + 'Shares In', + 'Shares Out', + 'Unit Price', + 'Amount', + 'Entry Fee', + 'Balance Shares', + 'Balance Value' + ], + this.lines.map((l) => [ + l.transactionDate, + this.typeLabel(l.typeEnum), + l.resolutionNumber, + this.funding(l.fundingSource), + l.sharesIn || '', + l.sharesOut || '', + l.unitPrice, + l.amount, + l.chargeAmount, + l.balanceShares, + l.balanceValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.html b/src/app/shares/reports/share-area-wise/share-area-wise.component.html new file mode 100644 index 000000000..58d02b8ce --- /dev/null +++ b/src/app/shares/reports/share-area-wise/share-area-wise.component.html @@ -0,0 +1,174 @@ + + +

+
+
+ +
+

Area Wise Shareholding

+
+ Where the society's members and its share capital sit — grouped by the member's own recorded address. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.areaCount | number }}
+
Areas
+
+
+
+ +
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Held
+
+
+
+ } + + @if (loading) { +
Loading area-wise shareholding…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!members.length) { +
No member holds share capital yet.
+ } @else { + +
+ + + + + + + + + + + + + @for (a of areas; track a.area) { + + + + + + + + + } + + + + + + + + + + + +
AreaMembersCapitalSharesShare Value% of Capital
+ {{ a.area }} + @if (areaFilter === a.area) { + filtered + } + {{ a.memberCount | number }} +
+
{{ a.shares | number }}₹{{ a.shareValue | number: '1.2-2' }}{{ capitalShare(a) | number: '1.2-2' }}%
All areas{{ totals?.memberCount | number }}{{ totals?.shares | number }}₹{{ totals?.shareValue | number: '1.2-2' }}100.00%
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (!filteredMembers.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + @for (m of filteredMembers; track m.clientId) { + + + + + + + + + + + } + +
AreaMemberMembership NoMobileBranchPostal CodeShares HeldShare Value
{{ m.area }}{{ m.clientName }}{{ m.cifNumber || '—' }}{{ m.mobileNo || '—' }}{{ m.officeName || '—' }}{{ m.postalCode || '—' }}{{ m.sharesHeld | number }}₹{{ m.shareValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.ts b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts new file mode 100644 index 000000000..d2abd920e --- /dev/null +++ b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts @@ -0,0 +1,188 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface AreaRow { + area: string; + memberCount: number; + shares: number; + shareValue: number; +} + +interface AreaMemberRow { + clientId: number; + cifNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + area: string; + postalCode: string; + sharesHeld: number; + shareValue: number; +} + +/** + * Area Wise — where the society's members and its share capital actually sit. + * + *

+ * A co-operative's area of operation is part of its licence, so "how much of our capital comes from + * which area" is a question both the board and the Registrar ask. Grouped on the member's own + * recorded address rather than the branch that serves them: the two answer different questions, and + * a licence is written against the address. + * + *

+ * Members with no address are their own group rather than dropped — an unaddressable member is a gap + * in the register worth seeing. + */ +@Component({ + selector: 'mifosx-share-area-wise', + templateUrl: './share-area-wise.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareAreaWiseComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + areas: AreaRow[] = []; + members: AreaMemberRow[] = []; + totals: any = null; + + search = ''; + /** Set by clicking an area row; '' means every area. */ + areaFilter = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareAreaWise({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The area-wise report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.areas = res?.areas || []; + this.members = res?.members || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredMembers(): AreaMemberRow[] { + const term = this.search.trim().toLowerCase(); + return this.members.filter((m) => { + if (this.areaFilter && m.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + m.clientName, + m.cifNumber, + m.area, + m.postalCode + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** An area's share of the society's capital — the reading this report exists for. */ + capitalShare(row: AreaRow): number { + const total = Number(this.totals?.shareValue || 0); + return total ? (Number(row.shareValue || 0) * 100) / total : 0; + } + + barWidth(row: AreaRow): number { + const largest = Math.max(...this.areas.map((a) => Number(a.shareValue || 0)), 1); + return (Number(row.shareValue || 0) * 100) / largest; + } + + selectArea(area: string): void { + this.areaFilter = this.areaFilter === area ? '' : area; + } + + openMember(row: AreaMemberRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'general' + ]); + } + + clearFilters(): void { + this.search = ''; + this.areaFilter = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-area-wise', + [ + 'Area', + 'Member', + 'Membership No', + 'Mobile', + 'Branch', + 'Postal Code', + 'Shares Held', + 'Share Value' + ], + this.filteredMembers.map((m) => [ + m.area, + m.clientName, + m.cifNumber, + m.mobileNo, + m.officeName, + m.postalCode, + m.sharesHeld, + m.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-detail-report/share-detail-report.component.html b/src/app/shares/reports/share-detail-report/share-detail-report.component.html new file mode 100644 index 000000000..6ca3fb45e --- /dev/null +++ b/src/app/shares/reports/share-detail-report/share-detail-report.component.html @@ -0,0 +1,192 @@ + + +

+
+
+ +
+

Share Detail

+
+ The complete share register — every account with its member, class and the terms it was opened under. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
₹{{ totals.chargeOutstanding | number: '1.2-2' }}
+
Charges Outstanding
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the share register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share account exists yet.
+ } @else if (!filteredRows.length) { +
No share account matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + + + + } + + + + + + + + + +
MemberMembership NoShare A/c NoBranchProductStateSharesValueTranchesBoard ResolutionLock-inMin ActiveLinked SavingsCharges O/SActivated
+ {{ row.clientName }} + @if (row.mobileNo) { +
{{ row.mobileNo }}
+ } +
{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
₹{{ row.shareValue | number: '1.2-2' }}{{ row.trancheCount | number }} + {{ row.resolutionNumber || '—' }} + @if (row.resolutionDate) { +
{{ row.resolutionDate }}
+ } +
{{ lockin(row) }}{{ minimumActive(row) }}{{ row.linkedSavingsAccountNo || '—' }} + {{ row.chargeOutstanding ? '₹' + (row.chargeOutstanding | number: '1.2-2') : '—' }} + {{ row.activatedDate || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts + {{ shownShares | number }}₹{{ shownValue | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-detail-report/share-detail-report.component.ts b/src/app/shares/reports/share-detail-report/share-detail-report.component.ts new file mode 100644 index 000000000..7a9323403 --- /dev/null +++ b/src/app/shares/reports/share-detail-report/share-detail-report.component.ts @@ -0,0 +1,293 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_ACCOUNT_STATUS, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + periodLabel, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface DetailRow { + shareAccountId: number; + accountNo: string; + externalId: string; + clientId: number; + cifNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + productId: number; + productName: string; + productShortName: string; + currencyCode: string; + statusEnum: number; + holdingState: string; + unpaidShares: number; + unitPrice: number; + sharesHeld: number; + shareValue: number; + pendingShares: number; + trancheCount: number; + resolutionNumber: string; + resolutionDate: string; + lockinPeriod: number; + lockinPeriodEnum: number; + minimumActivePeriod: number; + minimumActivePeriodEnum: number; + allowDividendsInactive: boolean; + linkedSavingsAccountNo: string; + chargeTotal: number; + chargeOutstanding: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * Share Detail — the complete register: every account with its member, share class, and the terms it + * was opened under. + * + *

+ * Where the Share Holder list answers "who holds shares", this answers "what exactly does this + * account say": lock-in, minimum active period, linked savings, charges, tranche count and governing + * resolution, which are otherwise spread one tab at a time across each account. + */ +@Component({ + selector: 'mifosx-share-detail-report', + templateUrl: './share-detail-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDetailReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: DetailRow[] = []; + totals: any = null; + + search = ''; + branchFilter = ''; + productFilter = ''; + statusFilter: number | '' = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareDetailRegister({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get statusOptions(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filteredRows(): DetailRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.statusFilter !== '' && r.statusEnum !== this.statusFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber, + r.externalId + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0); + } + + get shownValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter || this.statusFilter !== ''); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + lockin(row: DetailRow): string { + return periodLabel(row.lockinPeriod, row.lockinPeriodEnum); + } + + minimumActive(row: DetailRow): string { + return periodLabel(row.minimumActivePeriod, row.minimumActivePeriodEnum); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + openAccount(row: DetailRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-detail-register', + [ + 'Member', + 'Membership No', + 'Mobile', + 'Share A/c No', + 'External Id', + 'Branch', + 'Product', + 'Status', + 'Holding State', + 'Allotted Unpaid', + 'Shares Held', + 'Unit Price', + 'Share Value', + 'Pending Shares', + 'Tranches', + 'Board Resolution', + 'Lock-in', + 'Min Active Period', + 'Dividends If Inactive', + 'Linked Savings', + 'Charges', + 'Charges Outstanding', + 'Submitted', + 'Activated', + 'Closed' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.mobileNo, + r.accountNo, + r.externalId, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.unpaidShares, + r.sharesHeld, + r.unitPrice, + r.shareValue, + r.pendingShares, + r.trancheCount, + r.resolutionNumber, + this.lockin(r), + this.minimumActive(r), + r.allowDividendsInactive ? 'Yes' : 'No', + r.linkedSavingsAccountNo, + r.chargeTotal, + r.chargeOutstanding, + r.submittedDate, + r.activatedDate, + r.closedDate + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html new file mode 100644 index 000000000..7d552d1bd --- /dev/null +++ b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.html @@ -0,0 +1,152 @@ + + +

+
+
+ +
+

Share Distinctive Number Detail

+
+ Which numbered shares each member holds — the range every allotment covers, per share class. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.allotmentCount | number }}
+
Numbered Allotments
+
+
+
+ +
+
{{ totals.sharesNumbered | number }}
+
Shares Numbered
+
+
+
+ } + + +
+ + + Distinctive numbers are derived from issue order within each share class — no distinctive-number column + exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap + rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are + numbered. + +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the distinctive number register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No paid-up allotment has been made yet, so no share carries a number.
+ } @else if (!filteredRows.length) { +
+ @if (shareNumberLookup) { + Share number {{ shareNumberLookup | number }} has not been allotted in this share class. + } @else { + No allotment matches the filters applied. + } +
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transactionId) { + + + + + + + + + + + + + } + + + + + + + + +
Share ClassDistinctive NumbersSharesMemberMembership NoShare A/c NoBranchAllotment DateFunded ByBoard Resolution
{{ row.productName }} + {{ range(row) }} + {{ row.shares | number }}{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.allotmentDate }}{{ funding(row.fundingSource) }}{{ row.resolutionNumber || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments + {{ shownShares | number }}
+
+ } +
diff --git a/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts new file mode 100644 index 000000000..541745ac5 --- /dev/null +++ b/src/app/shares/reports/share-distinctive-numbers/share-distinctive-numbers.component.ts @@ -0,0 +1,206 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface DistinctiveRow { + transactionId: number; + allotmentDate: string; + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + shares: number; + unitPrice: number; + amount: number; + resolutionNumber: string; + fundingSource: string; + distinctiveFrom: number; + distinctiveTo: number; +} + +/** + * Share Devede (Distinctive) Number Detail — which numbered shares each member actually holds. + * + *

+ * A share certificate names the distinctive numbers it covers, and the register has to be able to + * say who holds which. Nothing in the schema records those numbers, so they are derived the way the + * society would have allotted them: shares numbered in issue order within each share class, each + * allotment taking the next block. That is stated plainly on the screen rather than presented as an + * independently-kept number, because it is a derivation and a reader should know it. + */ +@Component({ + selector: 'mifosx-share-distinctive-numbers', + templateUrl: './share-distinctive-numbers.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDistinctiveNumbersComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: DistinctiveRow[] = []; + totals: any = null; + + search = ''; + productFilter = ''; + /** Find who holds a given share number — the question a certificate query actually arrives as. */ + shareNumberLookup: number | null = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareDistinctiveNumbers({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The distinctive number register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): DistinctiveRow[] { + const term = this.search.trim().toLowerCase(); + const lookup = Number(this.shareNumberLookup); + return this.rows.filter((r) => { + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + // A share number belongs to exactly one block, so the lookup is a range test rather than a + // match — which is the whole point of holding the register this way. + if (lookup > 0 && !(lookup >= Number(r.distinctiveFrom) && lookup <= Number(r.distinctiveTo))) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.productFilter || Number(this.shareNumberLookup) > 0); + } + + funding(source: string): string { + return fundingLabel(source); + } + + range(row: DistinctiveRow): string { + return `${Number(row.distinctiveFrom).toLocaleString('en-IN')} – ${Number(row.distinctiveTo).toLocaleString('en-IN')}`; + } + + clearFilters(): void { + this.search = ''; + this.productFilter = ''; + this.shareNumberLookup = null; + } + + openAccount(row: DistinctiveRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-distinctive-numbers', + [ + 'Share Class', + 'Distinctive From', + 'Distinctive To', + 'Shares', + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Allotment Date', + 'Funded By', + 'Board Resolution' + ], + this.filteredRows.map((r) => [ + r.productName, + r.distinctiveFrom, + r.distinctiveTo, + r.shares, + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.allotmentDate, + this.funding(r.fundingSource), + r.resolutionNumber + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html new file mode 100644 index 000000000..de23d6383 --- /dev/null +++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.html @@ -0,0 +1,171 @@ + + +

+
+
+ +
+

Dividend Statement

+
+ Every dividend declared to a member, year by year, with what has been paid and what is still owed. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Loading the statement…
+ } @else if (matches.length) { +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberMembership No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !member) { +
No member with a share account matches "{{ search }}".
+ } @else if (member) { +
+
+
+
Member
+
{{ member.clientName }}
+
+
+
Membership No
+
{{ member.cifNumber || '—' }}
+
+
+
Branch
+
{{ member.officeName || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.declarationCount | number }}
+
Declarations
+
+
+
+ +
+
₹{{ totals.declared | number: '1.2-2' }}
+
Total Declared
+
+
+
+ +
+
₹{{ totals.paid | number: '1.2-2' }}
+
Paid
+
+
+
+ +
+
₹{{ totals.outstanding | number: '1.2-2' }}
+
Still Owed
+
+
+
+ } + + @if (!rows.length) { +
No dividend has been declared to this member yet.
+ } @else { +
+ + + + + + + + + + + + + + @for (row of rows; track row.dividendDetailId) { + + + + + + + + + + } + + + + + + + + +
Dividend PeriodShare A/c NoProductRateShares Held NowAmountStatus
{{ row.periodStart }} — {{ row.periodEnd }}{{ row.accountNo }}{{ row.productName }}{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}{{ row.sharesHeldNow | number }}₹{{ row.amount | number: '1.2-2' }} + {{ + statusLabel(row.statusEnum) + }} +
Total declared₹{{ totals?.declared | number: '1.2-2' }}
+
+ } + } @else { +
Search for a member above to see their dividend statement.
+ } +
diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts new file mode 100644 index 000000000..811f790be --- /dev/null +++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts @@ -0,0 +1,151 @@ +/** + * 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 { ActivatedRoute } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +/** + * Dividend Statement — every dividend declared to one member, with what has been paid and what is + * still owed. + * + *

+ * Handed to a member as their record, so declared and paid stay separate columns: "declared" and + * "paid" are different answers to "have I had my dividend", and a statement showing one amount + * cannot tell them apart. + */ +@Component({ + selector: 'mifosx-share-dividend-statement', + templateUrl: './share-dividend-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareDividendStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + matches: any[] = []; + member: any = null; + rows: any[] = []; + totals: any = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.loadByClient(Number(clientId)); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.fetch({ search: term }); + } + + loadByClient(clientId: number): void { + this.fetch({ clientId }); + } + + private fetch(params: { search?: string; clientId?: number }): void { + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareDividendStatement(params) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The dividend statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.matches = res?.matches || []; + this.member = res?.member || null; + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.searching = false; + this.searched = true; + }); + } + + private reset(): void { + this.matches = []; + this.member = null; + this.rows = []; + this.totals = null; + } + + statusLabel(status: number): string { + return SHARE_DIVIDEND_STATUS[status]?.label || String(status ?? ''); + } + + statusClass(status: number): string { + return SHARE_DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey'; + } + + exportCsv(): void { + if (!this.member) { + return; + } + shareReportCsv( + `dividend-statement-${this.member.cifNumber || this.member.clientId}`, + [ + 'Period From', + 'Period To', + 'Share A/c No', + 'Product', + 'Rate %', + 'Amount', + 'Status' + ], + this.rows.map((r) => [ + r.periodStart, + r.periodEnd, + r.accountNo, + r.productName, + r.ratePercentage, + r.amount, + this.statusLabel(r.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-enquiry/share-enquiry.component.html b/src/app/shares/reports/share-enquiry/share-enquiry.component.html new file mode 100644 index 000000000..deb67fa55 --- /dev/null +++ b/src/app/shares/reports/share-enquiry/share-enquiry.component.html @@ -0,0 +1,291 @@ + + +

+
+
+ +
+

Share Enquiry

+
+ One member's whole share position — accounts, movements, dividends and linked savings, in one answer. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Looking up the member…
+ } @else if (matches.length) { + +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberMembership No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !member) { +
No member with a share account matches "{{ search }}".
+ } @else if (member) { + +
+
+
+
Member
+
{{ member.clientName }}
+
+
+
Membership No
+
{{ member.cifNumber || '—' }}
+
+
+
Branch
+
{{ member.officeName || '—' }}
+
+
+
Mobile
+
{{ member.mobileNo || '—' }}
+
+
+
Member Since
+
{{ member.memberSince || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Value
+
+
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
₹{{ totals.dividendTotal | number: '1.2-2' }}
+
Dividend Declared
+
+
+
+ } + + +
+
+
+
+ + + + + + + + + + + + + + + + @for (a of accounts; track a.shareAccountId) { + + + + + + + + + + + + } + +
Share A/c NoProductStateShares HeldPendingShare ValueLinked SavingsActivated
{{ a.accountNo }}{{ a.productName }} + {{ + stateLabel(a.holdingState) + }} + + {{ a.sharesHeld | number }} + @if (a.holdingState === 'UNPAID') { +
{{ a.unpaidShares | number }} unpaid
+ } +
{{ a.pendingShares | number }}₹{{ a.shareValue | number: '1.2-2' }} + {{ a.linkedSavingsAccountNo || 'Not linked' }} + @if (a.linkedSavingsAccountNo) { +
+ Balance ₹{{ a.linkedSavingsBalance | number: '1.2-2' }} +
+ } +
{{ a.activatedDate || '—' }} + + +
+
+ + +
+
+
+ @if (!movements.length) { +
No share movement recorded for this member.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (m of movements; track m.transactionId) { + + + + + + + + + + + + } + +
DateShare A/c NoParticularsSharesUnit PriceAmountFunded ByBoard ResolutionStatus
{{ m.transactionDate }}{{ m.accountNo }} + {{ typeLabel(m.typeEnum) }} + {{ m.shares | number }}{{ m.unitPrice | number: '1.2-2' }}₹{{ m.amount | number: '1.2-2' }}{{ funding(m.fundingSource) }}{{ m.resolutionNumber || '—' }} + {{ + txnStatusLabel(m.statusEnum) + }} +
+
+ } + + +
+
+
+ @if (!dividends.length) { +
No dividend has been declared to this member yet.
+ } @else { +
+ + + + + + + + + + + + + @for (d of dividends; track d.dividendDetailId) { + + + + + + + + + } + + + + + + + + +
PeriodShare A/c NoProductRateAmountStatus
{{ d.periodStart }} — {{ d.periodEnd }}{{ d.accountNo }}{{ d.productName }}{{ d.ratePercentage ? (d.ratePercentage | number: '1.2-2') + '%' : '—' }}₹{{ d.amount | number: '1.2-2' }} + {{ + dividendStatusLabel(d.statusEnum) + }} +
Total declared₹{{ totals?.dividendTotal | number: '1.2-2' }}
+
+ } + } @else { +
Search for a member above to see their full share position.
+ } +
diff --git a/src/app/shares/reports/share-enquiry/share-enquiry.component.ts b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts new file mode 100644 index 000000000..34b306790 --- /dev/null +++ b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts @@ -0,0 +1,246 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_TXN_STATUS, + SHARE_TXN_TYPE, + fundingLabel, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** m_share_account_dividend_details.status — where a member's dividend has got to. */ +const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Declared', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--blue' }, + 600: { label: 'Paid', cls: 'srep-pill--green' } +}; + +/** + * Share Enquiry — everything about one member's share position in a single answer: their accounts, + * every movement, the dividends declared to them, and the savings account their money moves through. + * + *

+ * Built for the counter. Someone is standing in front of the clerk asking "how many shares do I + * hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read + * the wrong member's row out loud, so this is one search and one page. + * + *

+ * A search matching several members returns the list rather than picking one: guessing between two + * members with similar names is precisely the mistake this screen must not make. + */ +@Component({ + selector: 'mifosx-share-enquiry', + templateUrl: './share-enquiry.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareEnquiryComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + /** Populated when the search matched more than one member — the caller picks. */ + matches: any[] = []; + + member: any = null; + accounts: any[] = []; + movements: any[] = []; + dividends: any[] = []; + totals: any = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.loadByClient(Number(clientId)); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareEnquiry({ search: term }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The enquiry could not be run.'); + return of(null); + }) + ) + .subscribe((res: any) => this.apply(res)); + } + + loadByClient(clientId: number): void { + this.searching = true; + this.loadError = ''; + this.reset(); + this.sharesService + .getShareEnquiry({ clientId }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The member could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => this.apply(res)); + } + + private apply(res: any): void { + this.matches = res?.matches || []; + this.member = res?.member || null; + this.accounts = res?.accounts || []; + this.movements = res?.movements || []; + this.dividends = res?.dividends || []; + this.totals = res?.totals || null; + this.searching = false; + this.searched = true; + } + + private reset(): void { + this.matches = []; + this.member = null; + this.accounts = []; + this.movements = []; + this.dividends = []; + this.totals = null; + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + txnStatusLabel(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + txnStatusClass(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + dividendStatusLabel(status: number): string { + return DIVIDEND_STATUS[status]?.label || String(status ?? ''); + } + + dividendStatusClass(status: number): string { + return DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + openAccount(account: any): void { + this.router.navigate([ + '/clients', + this.member.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + openStatement(account: any): void { + this.router.navigate(['/shares/reports/account-statement'], { + queryParams: { shareAccountId: account.shareAccountId } + }); + } + + exportCsv(): void { + if (!this.member) { + return; + } + shareReportCsv( + `share-enquiry-${this.member.cifNumber || this.member.clientId}`, + [ + 'Date', + 'Share A/c No', + 'Particulars', + 'Shares', + 'Unit Price', + 'Amount', + 'Funded By', + 'Board Resolution', + 'Status' + ], + this.movements.map((m) => [ + m.transactionDate, + m.accountNo, + this.typeLabel(m.typeEnum), + m.shares, + m.unitPrice, + m.amount, + this.funding(m.fundingSource), + m.resolutionNumber, + this.txnStatusLabel(m.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-final-balance/share-final-balance.component.html b/src/app/shares/reports/share-final-balance/share-final-balance.component.html new file mode 100644 index 000000000..38b54fb05 --- /dev/null +++ b/src/app/shares/reports/share-final-balance/share-final-balance.component.html @@ -0,0 +1,208 @@ + + +

+
+
+ +
+

Share Final Balance

+
+ Share capital held by each member as on a date — purchases less redemptions, settled only. +
+
+
+
+ + +
+
+ + + @if (totals) { +
+
+ +
+
{{ totals.memberCount | number }}
+
Members Holding
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
₹{{ totals.amountRedeemed | number: '1.2-2' }}
+
Redeemed To Date
+
+
+
+ } + + + @if (hasUnpaidAllotments) { +
+ + + {{ unpaidAllotments.accountCount | number }} + {{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for + — {{ unpaidAllotments.shares | number }} shares, ₹{{ unpaidAllotments.amount | number: '1.2-2' }}. These are excluded above: share capital is what the society has received, not what it has agreed to issue. + Apply the member's collection to bring them in. + +
+ } + + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (loading) { +
Loading share balances…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No member held share capital as on {{ asOnDate }}.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + } + + + + + + + + + + + + +
MemberMembership NoShare A/c NoBranchProductStateShares HeldUnit PriceShare ValuePurchasedRedeemedLast Movement
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
{{ row.unitPrice | number: '1.2-2' }}{{ row.shareValue | number: '1.2-2' }}{{ row.sharesPurchased | number }}{{ row.sharesRedeemed | number }}{{ row.lastMovementDate || '—' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members + {{ shownShares | number }}₹{{ shownValue | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by + {{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital. +
+ } + } +
diff --git a/src/app/shares/reports/share-final-balance/share-final-balance.component.ts b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts new file mode 100644 index 000000000..63fb2500a --- /dev/null +++ b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts @@ -0,0 +1,279 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { + SHARE_ACCOUNT_STATUS, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */ +interface FinalBalanceRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + statusEnum: number; + holdingState: string; + unpaidShares: number; + unitPrice: number; + sharesHeld: number; + shareValue: number; + sharesPurchased: number; + sharesRedeemed: number; + amountSubscribed: number; + amountRedeemed: number; + firstMovementDate: string; + lastMovementDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * Final Balance — what each member's share capital stands at on a given date, and what the society's + * Share Capital account should therefore read. + * + *

+ * The date is the whole point of the report, so it is asked of the server rather than filtered in the + * browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to + * that date, which the rows already on screen cannot answer. Changing the date re-queries. + * + *

+ * Branch, product and search are client-side, because they only ever narrow rows already fetched and + * a round trip for each keystroke would be worse than useless. + */ +@Component({ + selector: 'mifosx-share-final-balance', + templateUrl: './share-final-balance.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareFinalBalanceComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: FinalBalanceRow[] = []; + totals: any = null; + + /** + * Shares allotted to a member whose money never arrived — the account was activated but its + * collection was never applied. + * + * Deliberately kept out of the balance (share capital is what the society received, not what it + * promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a + * reader wondering which, and an allotment sitting unpaid is the thing worth chasing. + */ + unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null; + + /** Server-side filter — changing it re-queries, since the balance itself depends on it. */ + asOnDate = ''; + /** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */ + includeZero = false; + + /** Client-side filters. */ + search = ''; + branchFilter = ''; + productFilter = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share balance report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.unpaidAllotments = res?.unpaidAllotments || null; + this.loading = false; + }); + } + + get hasUnpaidAllotments(): boolean { + return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): FinalBalanceRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** + * Totals for what is on screen. The server's own totals cover the unfiltered report and are shown + * beside these, so a narrowed view can never be mistaken for the society's whole share capital. + */ + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0); + } + + get shownValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + } + + openAccount(row: FinalBalanceRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-final-balance-${this.asOnDate || 'today'}`, + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Status', + 'Holding State', + 'Allotted Unpaid', + 'Shares Held', + 'Unit Price', + 'Share Value', + 'Shares Purchased', + 'Shares Redeemed', + 'Last Movement' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.unpaidShares, + r.sharesHeld, + r.unitPrice, + r.shareValue, + r.sharesPurchased, + r.sharesRedeemed, + r.lastMovementDate + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html new file mode 100644 index 000000000..55f5134b1 --- /dev/null +++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html @@ -0,0 +1,250 @@ + + +

+
+
+ +
+

Folio Statement

+
+ A member's folio in the share register — every account they hold, with the movements running across all of + them. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loadError) { +
{{ loadError }}
+ } @else if (searching) { +
Loading the folio…
+ } @else if (matches.length) { +
{{ matches.length }} members match. Choose one:
+
+ + + + + + + + + + @for (m of matches; track m.clientId) { + + + + + + } + +
MemberFolio No
{{ m.clientName }}{{ m.cifNumber || '—' }}Open
+
+ } @else if (searched && !folio) { +
No member with a share account matches "{{ search }}".
+ } @else if (folio) { +
+
+
+
Folio No
+
{{ folio.folioNumber || '—' }}
+
+
+
Member
+
{{ folio.clientName }}
+
+
+
Branch
+
{{ folio.officeName || '—' }}
+
+
+
Mobile
+
{{ folio.mobileNo || '—' }}
+
+
+
Member Since
+
{{ folio.memberSince || '—' }}
+
+
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Accounts In Folio
+
+
+
+ +
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Folio Value
+
+
+
+ +
+
{{ totals.movementCount | number }}
+
Movements Shown
+
+
+
+ } + + +
+
+
+
+ + + + + + + + + + + + + + + @for (a of accounts; track a.shareAccountId) { + + + + + + + + + + + } + + + + + + + + + + +
Share A/c NoProductStateShares HeldUnit PriceValueActivatedClosed
{{ a.accountNo }}{{ a.productName }} + {{ + stateLabel(a.holdingState) + }} + + {{ a.sharesHeld | number }} + @if (a.holdingState === 'UNPAID') { +
{{ a.unpaidShares | number }} unpaid
+ } +
{{ a.unitPrice | number: '1.2-2' }}₹{{ a.shareValue | number: '1.2-2' }}{{ a.activatedDate || '—' }}{{ a.closedDate || '—' }}
Folio total{{ totals?.sharesHeld | number }}₹{{ totals?.shareValue | number: '1.2-2' }}
+
+ + +
+
+
+ @if (!lines.length) { +
+ No settled movement in this folio{{ fromDate || toDate ? ' in the selected period' : '' }}. +
+ } @else { +
+ + + + + + + + + + + + + + + + @for (line of lines; track line.transactionId) { + + + + + + + + + + + + } + + + + + + + +
DateShare A/c NoProductParticularsShares InShares OutAmountFunded ByFolio Balance
{{ line.transactionDate }}{{ line.accountNo }}{{ line.productName }} + {{ typeLabel(line.typeEnum) }} + {{ line.sharesIn ? (line.sharesIn | number) : '' }}{{ line.sharesOut ? (line.sharesOut | number) : '' }}₹{{ line.amount | number: '1.2-2' }}{{ funding(line.fundingSource) }}{{ line.folioBalance | number }}
Closing folio balance{{ totals?.closingFolioBalance | number }}
+
+ } + } @else { +
Search for a member above to open their folio.
+ } +
diff --git a/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts new file mode 100644 index 000000000..07539a841 --- /dev/null +++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts @@ -0,0 +1,217 @@ +/** + * 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 { ActivatedRoute, Router } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { + SHARE_TXN_TYPE, + fundingLabel, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +/** + * Folio Statement — one member's folio in the share register: every account they hold and every + * movement across all of them, in one running sequence. + * + *

+ * Where the Account Statement is one account, a folio is the member. Someone holding two share + * classes has two accounts but one folio, and the register is kept by folio — so the running balance + * here is across accounts, which is the figure the register itself carries. + */ +@Component({ + selector: 'mifosx-share-folio-statement', + templateUrl: './share-folio-statement.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareFolioStatementComponent implements OnInit { + private sharesService = inject(SharesService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + search = ''; + searching = false; + searched = false; + loadError = ''; + + matches: any[] = []; + folio: any = null; + accounts: any[] = []; + lines: any[] = []; + totals: any = null; + + fromDate = ''; + toDate = ''; + + /** Held once a folio is open, so changing the period re-queries the same member. */ + private resolvedClientId: number | null = null; + + ngOnInit(): void { + const clientId = this.route.snapshot.queryParamMap.get('clientId'); + if (clientId) { + this.resolvedClientId = Number(clientId); + this.fetch({ clientId: this.resolvedClientId }); + } + } + + runSearch(): void { + const term = this.search.trim(); + if (!term) { + return; + } + this.resolvedClientId = null; + this.fetch({ search: term }); + } + + loadByClient(clientId: number): void { + this.resolvedClientId = clientId; + this.fetch({ clientId }); + } + + reloadPeriod(): void { + if (this.resolvedClientId) { + this.fetch({ clientId: this.resolvedClientId }); + } + } + + clearPeriod(): void { + this.fromDate = ''; + this.toDate = ''; + this.reloadPeriod(); + } + + private fetch(params: { search?: string; clientId?: number }): void { + this.searching = true; + this.loadError = ''; + this.sharesService + .getShareFolioStatement({ ...params, fromDate: this.fromDate, toDate: this.toDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The folio statement could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.matches = res?.matches || []; + this.folio = res?.folio || null; + this.accounts = res?.accounts || []; + this.lines = res?.lines || []; + this.totals = res?.totals || null; + if (this.folio) { + this.resolvedClientId = this.folio.clientId; + } + this.searching = false; + this.searched = true; + }); + } + + typeLabel(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? ''); + } + + typeClass(typeEnum: number): string { + return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + openAccount(account: any): void { + this.router.navigate([ + '/clients', + this.folio.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + if (!this.folio) { + return; + } + shareReportCsv( + `folio-statement-${this.folio.folioNumber || this.folio.clientId}`, + [ + 'Date', + 'Share A/c No', + 'Product', + 'Particulars', + 'Shares In', + 'Shares Out', + 'Unit Price', + 'Amount', + 'Funded By', + 'Board Resolution', + 'Folio Balance' + ], + this.lines.map((l) => [ + l.transactionDate, + l.accountNo, + l.productName, + this.typeLabel(l.typeEnum), + l.sharesIn || '', + l.sharesOut || '', + l.unitPrice, + l.amount, + this.funding(l.fundingSource), + l.resolutionNumber, + l.folioBalance + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html new file mode 100644 index 000000000..3c542cdb0 --- /dev/null +++ b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.html @@ -0,0 +1,162 @@ + + +

+
+
+ +
+

Guarantor Detail

+
+ Everyone standing surety on a live loan, with the shareholding they hold in the society. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.guarantorCount | number }}
+
Active Guarantors
+
+
+
+ +
+
₹{{ totals.guaranteedOutstanding | number: '1.2-2' }}
+
Guaranteed Outstanding
+
+
+
+ +
+
₹{{ totals.guarantorShareValue | number: '1.2-2' }}
+
Guarantors' Share Value
+
+
+
+ +
+
{{ totals.membersWithoutShares | number }}
+
Members Without Shares
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading guarantors…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No guarantor stands on a loan at present.
+ } @else if (!filteredRows.length) { +
No guarantor matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + @for (row of filteredRows; track row.guarantorId) { + + + + + + + + + + + + } + + + + + + + + + + +
GuarantorTypeMembership NoBorrowerLoan A/c NoLoan OutstandingShare A/c NoShares HeldShare Value
+ {{ row.guarantorName || '—' }} + @if (row.guarantorMobile) { +
{{ row.guarantorMobile }}
+ } +
+ {{ row.guarantorType === 'MEMBER' ? 'Member' : 'External' }} + {{ row.guarantorCif || '—' }}{{ row.borrowerName }}{{ row.loanAccountNo }}₹{{ row.totalOutstanding | number: '1.2-2' }}{{ row.shareAccountNo || '—' }} + @if (isUncovered(row)) { + No shares + } @else { + {{ row.sharesHeld | number }} + } + ₹{{ row.shareValue | number: '1.2-2' }}
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} guarantors + ₹{{ shownOutstanding | number: '1.2-2' }}₹{{ shownShareValue | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts new file mode 100644 index 000000000..3f62e7025 --- /dev/null +++ b/src/app/shares/reports/share-guarantor-report/share-guarantor-report.component.ts @@ -0,0 +1,208 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface GuarantorRow { + guarantorId: number; + loanId: number; + loanAccountNo: string; + loanStatusId: number; + loanPrincipal: number; + principalOutstanding: number; + totalOutstanding: number; + borrowerName: string; + borrowerCif: string; + guarantorTypeEnum: number; + guarantorType: 'MEMBER' | 'EXTERNAL'; + guarantorName: string; + guarantorCif: string; + guarantorClientId: number; + guarantorOfficeName: string; + guarantorMobile: string; + shareAccountId: number; + shareAccountNo: string; + shareProductName: string; + sharesHeld: number; + shareValue: number; +} + +/** + * Guarantor Detail — everyone standing surety on a live loan, with the shareholding they hold in the + * society. + * + *

+ * A co-operative accepts a member as surety on the strength of their stake in it, so the exposure + * and the stake belong on one line. The case this report exists for is the member who stood surety + * and has since redeemed their shares: nothing on the loan screen or the share screen shows it, and + * it is flagged here rather than left to be noticed. + */ +@Component({ + selector: 'mifosx-share-guarantor-report', + templateUrl: './share-guarantor-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareGuarantorReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: GuarantorRow[] = []; + totals: any = null; + + search = ''; + typeFilter = ''; + /** Members standing surety with no shareholding left — the exposure worth isolating. */ + onlyWithoutShares = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareGuarantorDetail({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The guarantor report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredRows(): GuarantorRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.typeFilter && r.guarantorType !== this.typeFilter) { + return false; + } + if (this.onlyWithoutShares && !(r.guarantorType === 'MEMBER' && !Number(r.sharesHeld))) { + return false; + } + if (!term) { + return true; + } + return [ + r.guarantorName, + r.guarantorCif, + r.borrowerName, + r.loanAccountNo, + r.shareAccountNo + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownOutstanding(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.totalOutstanding || 0), 0); + } + + get shownShareValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.typeFilter || this.onlyWithoutShares); + } + + /** A member guarantor holding nothing is the exposure; an external one never held any. */ + isUncovered(row: GuarantorRow): boolean { + return row.guarantorType === 'MEMBER' && !Number(row.sharesHeld); + } + + openGuarantorShares(row: GuarantorRow): void { + if (row.guarantorClientId && row.shareAccountId) { + this.router.navigate([ + '/clients', + row.guarantorClientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + } + + clearFilters(): void { + this.search = ''; + this.typeFilter = ''; + this.onlyWithoutShares = false; + } + + exportCsv(): void { + shareReportCsv( + 'share-guarantor-detail', + [ + 'Guarantor', + 'Type', + 'Membership No', + 'Branch', + 'Mobile', + 'Borrower', + 'Loan A/c No', + 'Loan Outstanding', + 'Share A/c No', + 'Share Product', + 'Shares Held', + 'Share Value' + ], + this.filteredRows.map((r) => [ + r.guarantorName, + r.guarantorType, + r.guarantorCif, + r.guarantorOfficeName, + r.guarantorMobile, + r.borrowerName, + r.loanAccountNo, + r.totalOutstanding, + r.shareAccountNo, + r.shareProductName, + r.sharesHeld, + r.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-issued-report/share-issued-report.component.html b/src/app/shares/reports/share-issued-report/share-issued-report.component.html new file mode 100644 index 000000000..74801229d --- /dev/null +++ b/src/app/shares/reports/share-issued-report/share-issued-report.component.html @@ -0,0 +1,204 @@ + + +

+
+
+ +
+

Share Issued Register

+
+ Every tranche of shares allotted in the period, with the board resolution behind it and how it was paid for. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.trancheCount | number }}
+
Allotments
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Issued
+
+
+
+ +
+
₹{{ totals.amount | number: '1.2-2' }}
+
Capital Raised
+
+
+
+ +
+
{{ missingResolutionCount | number }}
+
Without Resolution
+
+
+
+ } + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the issue register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No shares were allotted between {{ fromDate }} and {{ toDate }}.
+ } @else if (!filteredRows.length) { +
No allotment matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transactionId) { + + + + + + + + + + + + + + + + } + + + + + + + + + + + +
Issue DateMemberMembership NoShare A/c NoBranchProductSharesUnit PriceAmountEntry FeeFunded ByBoard ResolutionStatus
{{ row.issueDate }}{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.officeName || '—' }}{{ row.productName }}{{ row.shares | number }}{{ row.unitPrice | number: '1.2-2' }}{{ row.amount | number: '1.2-2' }}{{ row.chargeAmount ? (row.chargeAmount | number: '1.2-2') : '—' }}{{ funding(row.fundingSource) }} + @if (row.resolutionNumber) { + {{ row.resolutionNumber }} + @if (row.meetingDate) { +
{{ row.meetingDate }}
+ } + } @else { + Not recorded + } +
+ {{ + txnStatusLabel(row.statusEnum) + }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments + {{ shownShares | number }}₹{{ shownAmount | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued + to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{ + totals.amount | number: '1.2-2' + }}. +
+ } + } +
diff --git a/src/app/shares/reports/share-issued-report/share-issued-report.component.ts b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts new file mode 100644 index 000000000..abefc22c0 --- /dev/null +++ b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts @@ -0,0 +1,264 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface IssuedRow { + transactionId: number; + issueDate: string; + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + shares: number; + unitPrice: number; + amount: number; + chargeAmount: number; + statusEnum: number; + resolutionNumber: string; + meetingDate: string; + fundingSource: string; + accountStatusEnum: number; +} + +/** + * Share Issued — the allotment register: every tranche of shares issued in a period, with the board + * resolution that authorised it and how the member paid. + * + *

+ * One row per tranche, not per member: a member who bought three times was allotted three times, and + * the register records allotments. Rolling them up per member would lose the resolution each one was + * made under, which is the column an auditor actually reads. + * + *

+ * Period and status are server-side (the register is a period document, and "what is pending" is a + * different query, not a different view of the same rows); branch, product and search are local. + */ +@Component({ + selector: 'mifosx-share-issued-report', + templateUrl: './share-issued-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareIssuedReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: IssuedRow[] = []; + totals: any = null; + + // ── Server-side ── + fromDate = ''; + toDate = ''; + /** '' = settled allotments only (the register's default); 100 = pending; 400 = rejected. */ + statusFilter: number | '' = ''; + + // ── Client-side ── + search = ''; + branchFilter = ''; + productFilter = ''; + fundingFilter = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + // Opens on the current financial year, which is the period this register is almost always run + // for. April opens the FY in this system, matching the Share Capital Return. + const fyStart = new Date(businessDate.getFullYear(), businessDate.getMonth() >= 3 ? 3 : -9, 1); + this.fromDate = this.toIso(fyStart); + this.toDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareIssuedReport({ + fromDate: this.fromDate, + toDate: this.toDate, + status: this.statusFilter === '' ? undefined : this.statusFilter + }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share issue register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get fundingOptions(): string[] { + return [...new Set(this.rows.map((r) => fundingLabel(r.fundingSource)))].sort(); + } + + get filteredRows(): IssuedRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.fundingFilter && fundingLabel(r.fundingSource) !== this.fundingFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName, + r.resolutionNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownShares(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0); + } + + get shownAmount(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.amount || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.productFilter || this.fundingFilter); + } + + /** Tranches with no resolution recorded — the one thing an auditor opens this register to find. */ + get missingResolutionCount(): number { + return this.filteredRows.filter((r) => !r.resolutionNumber).length; + } + + txnStatusLabel(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + txnStatusClass(statusEnum: number): string { + return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.fundingFilter = ''; + } + + openAccount(row: IssuedRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-issued-${this.fromDate}-to-${this.toDate}`, + [ + 'Issue Date', + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Amount', + 'Entry Fee', + 'Funded By', + 'Board Resolution', + 'Meeting Date', + 'Status' + ], + this.filteredRows.map((r) => [ + r.issueDate, + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + r.shares, + r.unitPrice, + r.amount, + r.chargeAmount, + this.funding(r.fundingSource), + r.resolutionNumber, + r.meetingDate, + this.txnStatusLabel(r.statusEnum) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-label/share-label.component.html b/src/app/shares/reports/share-label/share-label.component.html new file mode 100644 index 000000000..97f092a77 --- /dev/null +++ b/src/app/shares/reports/share-label/share-label.component.html @@ -0,0 +1,124 @@ + + +

+
+
+ +
+

Share Labels

+
+ Printable address labels for shareholding members — ready for an ordinary label sheet. +
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the mailing list…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!filteredRows.length) { +
No shareholding member matches the filters applied.
+ } @else { +
+ {{ printableRows.length | number }} labels ready to print + @if (unaddressableRows.length) { + · {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted + to + } +
+ +
+ @for (row of printableRows; track row.clientId) { +
+
{{ row.clientName }}
+ @for (line of lines(row); track line) { +
{{ line }}
+ } + @if (showFolio && row.folioNumber) { +
Folio {{ row.folioNumber }}
+ } + @if (showHolding) { +
{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}
+ } +
+ } +
+ + + @if (unaddressableRows.length) { +
+ + + {{ unaddressableRows.length | number }} shareholding member{{ + unaddressableRows.length === 1 ? ' has' : 's have' + }} + no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }} + @if (unaddressableRows.length > 1) { + and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }} + } + . Record an address against the member to include them in the next run. + +
+ } + } +
diff --git a/src/app/shares/reports/share-label/share-label.component.ts b/src/app/shares/reports/share-label/share-label.component.ts new file mode 100644 index 000000000..b9268f267 --- /dev/null +++ b/src/app/shares/reports/share-label/share-label.component.ts @@ -0,0 +1,197 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +export interface MailingRow { + clientId: number; + folioNumber: string; + clientName: string; + mobileNo: string; + officeName: string; + addressLine1: string; + addressLine2: string; + street: string; + city: string; + district: string; + postalCode: string; + area: string; + sharesHeld: number; + shareValue: number; + shareAccountNos: string; + addressable: boolean; +} + +/** Assembles one member's address into the lines an envelope actually carries. */ +export function addressLines(row: MailingRow): string[] { + return [ + row.addressLine1, + row.addressLine2, + row.street, + [ + row.city, + row.district + ] + .filter(Boolean) + .join(', '), + row.postalCode ? `PIN ${row.postalCode}` : '' + ] + .map((line) => (line || '').trim()) + .filter(Boolean); +} + +/** + * Share Label — printable address labels for shareholding members. + * + *

+ * Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a + * mailing run needs no separate mail-merge tool. Members with no usable address are listed + * separately rather than printed as a blank label: a blank label is a wasted sheet and a member who + * silently never hears from the society. + */ +@Component({ + selector: 'mifosx-share-label', + templateUrl: './share-label.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareLabelComponent implements OnInit { + private sharesService = inject(SharesService); + + loading = true; + loadError = ''; + rows: MailingRow[] = []; + totals: any = null; + + search = ''; + areaFilter = ''; + /** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */ + columns = 3; + showFolio = true; + showHolding = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareMailingList({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The mailing list could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get areaOptions(): string[] { + return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort(); + } + + /** Only members who can actually be posted to — the rest are listed below, not printed. */ + get printableRows(): MailingRow[] { + return this.filteredRows.filter((r) => r.addressable); + } + + get unaddressableRows(): MailingRow[] { + return this.filteredRows.filter((r) => !r.addressable); + } + + get filteredRows(): MailingRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.areaFilter && r.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.folioNumber, + r.area, + r.postalCode + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + lines(row: MailingRow): string[] { + return addressLines(row); + } + + clearFilters(): void { + this.search = ''; + this.areaFilter = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-mailing-labels', + [ + 'Member', + 'Folio No', + 'Address', + 'City', + 'District', + 'Postal Code', + 'Mobile', + 'Shares Held', + 'Addressable' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.folioNumber, + this.lines(r).join(' / '), + r.city, + r.district, + r.postalCode, + r.mobileNo, + r.sharesHeld, + r.addressable ? 'Yes' : 'No' + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-letter/share-letter.component.html b/src/app/shares/reports/share-letter/share-letter.component.html new file mode 100644 index 000000000..a30b55f7a --- /dev/null +++ b/src/app/shares/reports/share-letter/share-letter.component.html @@ -0,0 +1,171 @@ + + +

+
+
+ +
+

Share Letter

+
+ A letter to shareholding members, merged with their own details and printed — one page each. +
+
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ Merged per member: + @for (p of placeholders; track p) { + {{ p }}{{ $last ? '.' : ', ' }} + } + Anything else you write is sent as it stands. +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading the mailing list…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!filteredRows.length) { +
No shareholding member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + } + +
MemberFolio NoAreaSharesPostable
+ + {{ row.clientName }}{{ row.folioNumber || '—' }}{{ row.area }}{{ row.sharesHeld | number }} + @if (row.addressable) { + Yes + } @else { + No address + } +
+
+ +
+ {{ recipients.length | number }} letter{{ recipients.length === 1 ? '' : 's' }} will print + @if (!selectedIds.length) { + (everyone shown — tick rows to narrow it) + } +
+ + + @for (row of recipients; track row.clientId) { +
+
+
{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}
+ @if (institution?.addressLine1 || institution?.address) { +
{{ institution?.addressLine1 || institution?.address }}
+ } +
+ +
+
+
{{ row.clientName }}
+ @for (line of lines(row); track line) { +
{{ line }}
+ } + @if (row.folioNumber) { +
Folio {{ row.folioNumber }}
+ } +
+
Date: {{ today }}
+
+ + @if (subject.trim()) { +
Subject: {{ subject }}
+ } + +
Dear {{ row.clientName }},
+
{{ merged(row) }}
+ +
+
Yours faithfully,
+
+
+
Authorised Signatory
+
+
+
+ } + } +
diff --git a/src/app/shares/reports/share-letter/share-letter.component.ts b/src/app/shares/reports/share-letter/share-letter.component.ts new file mode 100644 index 000000000..056cdb00a --- /dev/null +++ b/src/app/shares/reports/share-letter/share-letter.component.ts @@ -0,0 +1,227 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { MailingRow, addressLines } from '../share-label/share-label.component'; +import { shareReportPrint } from '../share-report.util'; + +/** The letters a society actually sends its shareholders, as starting text. */ +const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [ + { + key: 'AGM', + label: 'Notice of Annual General Meeting', + subject: 'Notice of the Annual General Meeting', + body: + 'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' + + 'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' + + 'Your presence is requested.' + }, + { + key: 'DIVIDEND', + label: 'Dividend intimation', + subject: 'Dividend declared on your shareholding', + body: + 'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' + + 'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' + + 'linked savings account. Please contact your branch if it is not received within fifteen days.' + }, + { + key: 'SHARE_CALL', + label: 'Call to complete shareholding', + subject: 'Completion of your share subscription', + body: + 'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' + + 'minimum subscription your share class requires.\n\n' + + 'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' + + 'remain unaffected.' + }, + { + key: 'BLANK', + label: 'Blank letter', + subject: '', + body: '' + } +]; + +/** + * Share Letter — a letter to shareholding members, merged with their own details and printed. + * + *

+ * Runs off the same mailing list the labels do, so the letter and the envelope always agree on who + * is being written to. Templates are starting text rather than fixed forms: the body is editable + * before printing, because the letter a society actually sends is never quite the one a system + * imagined. + * + *

+ * Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit + * produces every letter, each addressed to the member it is going to. + */ +@Component({ + selector: 'mifosx-share-letter', + templateUrl: './share-letter.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareLetterComponent implements OnInit { + private sharesService = inject(SharesService); + private organizationService = inject(OrganizationService); + + loading = true; + loadError = ''; + rows: MailingRow[] = []; + institution: any = null; + + readonly templates = TEMPLATES; + templateKey = 'AGM'; + subject = TEMPLATES[0].subject; + body = TEMPLATES[0].body; + + search = ''; + areaFilter = ''; + /** Which members this run is for; empty means everyone shown. */ + selectedIds: number[] = []; + + ngOnInit(): void { + this.sharesService + .getShareMailingList({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The mailing list could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.loading = false; + }); + + this.organizationService + .getInstitutionProfile() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.institution = res)); + } + + applyTemplate(): void { + const template = TEMPLATES.find((t) => t.key === this.templateKey); + if (template) { + this.subject = template.subject; + this.body = template.body; + } + } + + get areaOptions(): string[] { + return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort(); + } + + get filteredRows(): MailingRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.areaFilter && r.area !== this.areaFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.folioNumber, + r.area + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + /** Nothing ticked means the whole filtered list — the common case is "write to all of these". */ + get recipients(): MailingRow[] { + if (!this.selectedIds.length) { + return this.filteredRows; + } + return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId)); + } + + isSelected(clientId: number): boolean { + return this.selectedIds.includes(clientId); + } + + toggle(clientId: number): void { + this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [ + ...this.selectedIds, + clientId + ]; + } + + selectAll(): void { + this.selectedIds = []; + } + + lines(row: MailingRow): string[] { + return addressLines(row); + } + + /** What one member's letter substitutes, keyed by the token that stands for it in the body. */ + private mergeFields(row: MailingRow): Record { + return { + NAME: row.clientName || '', + FOLIO: row.folioNumber || '', + SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'), + SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'), + SHARE_ACCOUNTS: row.shareAccountNos || '', + BRANCH: row.officeName || '', + AREA: row.area || '' + }; + } + + /** + * The tokens the editor's help text offers, derived from the merge itself so the two can never + * drift apart — and assembled here rather than written in the template, because a literal + * `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see. + */ + readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`); + + /** The member's own details merged into the body, one letter at a time. */ + merged(row: MailingRow): string { + const fields = this.mergeFields(row); + // An unrecognised token is left standing rather than blanked: a letter that visibly still says + // {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it. + return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token); + } + + get today(): string { + const now = new Date(); + return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`; + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html new file mode 100644 index 000000000..166169c80 --- /dev/null +++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html @@ -0,0 +1,166 @@ + + +

+
+
+ +
+

Total Interest Received & Receivable

+
+ Interest earned from the borrowings of the members who hold the society's share capital. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
₹{{ totals.interestReceived | number: '1.2-2' }}
+
Interest Received
+
+
+
+ +
+
₹{{ totals.interestReceivable | number: '1.2-2' }}
+
Interest Receivable
+
+
+
+ +
+
{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}
+
Borrowing Members
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Their Share Capital
+
+
+
+ } + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading interest figures…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No member holds share capital yet.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.clientId) { + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + +
MemberMembership NoBranchSharesShare ValueLoansPrincipal O/SInterest ChargedInterest ReceivedInterest ReceivableWaivedWritten Off
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.officeName || '—' }}{{ row.sharesHeld | number }}₹{{ row.shareValue | number: '1.2-2' }}{{ row.loanCount | number }}₹{{ row.principalOutstanding | number: '1.2-2' }}₹{{ row.interestCharged | number: '1.2-2' }}₹{{ row.interestReceived | number: '1.2-2' }}₹{{ row.interestReceivable | number: '1.2-2' }}{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }} + {{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members + {{ sum('sharesHeld') | number }}₹{{ sum('shareValue') | number: '1.2-2' }}{{ sum('loanCount') | number }}₹{{ sum('principalOutstanding') | number: '1.2-2' }}₹{{ sum('interestCharged') | number: '1.2-2' }}₹{{ sum('interestReceived') | number: '1.2-2' }}₹{{ sum('interestReceivable') | number: '1.2-2' }}₹{{ sum('interestWaived') | number: '1.2-2' }}₹{{ sum('interestWrittenOff') | number: '1.2-2' }}
+
+ } +
diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts new file mode 100644 index 000000000..e87fe1544 --- /dev/null +++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts @@ -0,0 +1,197 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface MemberInterestRow { + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + sharesHeld: number; + shareValue: number; + loanCount: number; + principalLent: number; + principalOutstanding: number; + interestCharged: number; + interestReceived: number; + interestReceivable: number; + interestWaived: number; + interestWrittenOff: number; +} + +/** + * Total Interest Received & Receivable — what the society has earned in interest from the borrowings + * of the members who hold its share capital. + * + *

+ * Shares earn dividend, not interest; this is the other side of the member relationship. A member's + * stake and what their lending returns is the case for admitting and keeping them, and a + * co-operative reads the two together. + * + *

+ * Received and receivable stay in separate columns rather than being summed — interest charged but + * not yet collected is not income in hand, and a single "interest" figure hides exactly that. + */ +@Component({ + selector: 'mifosx-share-member-interest-report', + templateUrl: './share-member-interest-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareMemberInterestReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: MemberInterestRow[] = []; + totals: any = null; + + /** Server-side: a closed loan's interest is history, not a position. */ + includeClosedLoans = false; + + search = ''; + branchFilter = ''; + /** Members who hold shares but have never borrowed are noise when the question is interest. */ + onlyBorrowers = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareMemberLoanInterest({ includeClosedLoans: this.includeClosedLoans }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The interest report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get filteredRows(): MemberInterestRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.onlyBorrowers && !Number(r.loanCount)) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.cifNumber + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + sum(field: keyof MemberInterestRow): number { + return this.filteredRows.reduce((total, row) => total + Number(row[field] || 0), 0); + } + + get isFiltered(): boolean { + return !!(this.search.trim() || this.branchFilter || this.onlyBorrowers); + } + + openMember(row: MemberInterestRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'general' + ]); + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.onlyBorrowers = false; + } + + exportCsv(): void { + shareReportCsv( + 'share-member-interest', + [ + 'Member', + 'Membership No', + 'Branch', + 'Shares Held', + 'Share Value', + 'Loans', + 'Principal Lent', + 'Principal Outstanding', + 'Interest Charged', + 'Interest Received', + 'Interest Receivable', + 'Interest Waived', + 'Interest Written Off' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.officeName, + r.sharesHeld, + r.shareValue, + r.loanCount, + r.principalLent, + r.principalOutstanding, + r.interestCharged, + r.interestReceived, + r.interestReceivable, + r.interestWaived, + r.interestWrittenOff + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html new file mode 100644 index 000000000..9891d0e39 --- /dev/null +++ b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.html @@ -0,0 +1,244 @@ + + +

+
+
+ +
+

Nominal Share Report

+
+ Each member's holding at face value, measured against the share class's nominal, minimum and maximum. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
₹{{ totals.nominalValue | number: '1.2-2' }}
+
Nominal Capital
+
+
+
+ +
+
{{ totals.regularMembers | number }}
+
Regular Members
+
+
+
+ +
+
{{ totals.nominalMembers | number }}
+
Nominal Members
+
+
+
+ +
+
{{ totals.belowMinimum + totals.aboveMaximum | number }}
+
Outside Limits
+
+
+
+ } + + + @if (unpaidRows.length) { +
+ + + {{ unpaidRows.length | number }} {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares + allotted but never collected — {{ unpaidShares | number }} shares in all. They are active, so they are + listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's + collection is applied. + +
+ } + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (loading) { +
Loading nominal share positions…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share account existed as on {{ asOnDate }}.
+ } @else if (!filteredRows.length) { +
No member matches the filters applied.
+ } @else { + @if (hiddenByStateCount) { +
+ {{ hiddenByStateCount | number }} closed, rejected or not-yet-active + {{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch Account state to see + them. +
+ } + +
+ + + + + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.shareAccountId) { + + + + + + + + + + + + + + + + + } + + + + + + + + +
MemberMembership NoShare A/c NoProductStateHeldNominalvs NominalMinMaxFace ValueNominal ValueClassCompliance
{{ row.clientName }}{{ row.cifNumber || '—' }}{{ row.accountNo }}{{ row.productName }} + {{ stateLabel(row.holdingState) }} + + {{ row.sharesHeld | number }} + @if (row.holdingState === 'UNPAID') { +
{{ row.unpaidShares | number }} unpaid
+ } +
{{ row.nominalShares | number }}{{ row.againstNominal > 0 ? '+' : '' }}{{ row.againstNominal | number }}{{ row.minimumShares | number }}{{ row.maximumShares | number }}{{ row.faceValue | number: '1.2-2' }}₹{{ row.nominalValue | number: '1.2-2' }} + {{ + membershipLabel(row.membershipClass) + }} + + {{ + complianceLabel(row.complianceStatus) + }} +
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts + ₹{{ shownNominalValue | number: '1.2-2' }}
+
+ + @if (isFiltered && totals) { +
+ Showing a filtered view. Society-wide: ₹{{ totals.nominalValue | number: '1.2-2' }} nominal capital, + {{ totals.regularMembers | number }} regular and {{ totals.nominalMembers | number }} nominal members, + {{ totals.belowMinimum + totals.aboveMaximum | number }} outside the class's limits. +
+ } + } +
diff --git a/src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts new file mode 100644 index 000000000..78506b617 --- /dev/null +++ b/src/app/shares/reports/share-nominal-report/share-nominal-report.component.ts @@ -0,0 +1,337 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { + SHARE_ACCOUNT_STATUS, + SHARE_HOLDING_STATE_ORDER, + holdingStateClass, + holdingStateLabel, + holdingStateNote, + holdingStateRowClass, + shareReportCsv, + shareReportPrint +} from '../share-report.util'; + +interface NominalRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + statusEnum: number; + faceValue: number; + nominalShares: number; + minimumShares: number; + maximumShares: number; + sharesHeld: number; + nominalValue: number; + againstNominal: number; + complianceStatus: 'NO_HOLDING' | 'BELOW_MINIMUM' | 'ABOVE_MAXIMUM' | 'WITHIN_LIMITS' | 'NOT_APPLICABLE'; + membershipClass: 'NONE' | 'NOMINAL' | 'REGULAR' | 'NOT_APPLICABLE'; + holdingState: string; + unpaidShares: number; + activatedDate: string; +} + +/** What a compliance state is called and how it is flagged. */ +const COMPLIANCE: { [key: string]: { label: string; cls: string } } = { + WITHIN_LIMITS: { label: 'Within limits', cls: 'srep-pill--green' }, + BELOW_MINIMUM: { label: 'Below minimum', cls: 'srep-pill--red' }, + ABOVE_MAXIMUM: { label: 'Above maximum', cls: 'srep-pill--red' }, + NO_HOLDING: { label: 'No holding', cls: 'srep-pill--grey' }, + // A closed or rejected account is not subject to the class's minimum any more, so it is excused + // rather than reported as breaching it. + NOT_APPLICABLE: { label: 'Not applicable', cls: 'srep-pill--grey' } +}; + +const MEMBERSHIP: { [key: string]: { label: string; cls: string } } = { + REGULAR: { label: 'Regular', cls: 'srep-pill--green' }, + NOMINAL: { label: 'Nominal', cls: 'srep-pill--amber' }, + NONE: { label: '—', cls: 'srep-pill--grey' }, + NOT_APPLICABLE: { label: '—', cls: 'srep-pill--grey' } +}; + +/** + * Nominal Share Report — each member's holding read against the three numbers the share class + * itself sets: its nominal subscription, its minimum and its maximum. + * + *

+ * The report exists to find breaches, so the default view is every member and the breaches are + * flagged in place; the compliance filter is there for when the answer needed is only "who is + * short". + */ +@Component({ + selector: 'mifosx-share-nominal-report', + templateUrl: './share-nominal-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareNominalReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + private router = inject(Router); + + loading = true; + loadError = ''; + rows: NominalRow[] = []; + totals: any = null; + stateCounts: any = null; + + asOnDate = ''; + search = ''; + branchFilter = ''; + productFilter = ''; + complianceFilter = ''; + membershipFilter = ''; + /** + * Which accounts to show. Defaults to the live ones — a compliance report is read as "who is + * short today", and a closed member cannot be short of anything. Closed and rejected accounts are + * one dropdown away rather than gone, and the count of what the default hides is shown above the + * table so the narrowing is never silent. + */ + stateFilter = 'LIVE'; + readonly stateOrder = SHARE_HOLDING_STATE_ORDER; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareNominalReport({ asOnDate: this.asOnDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The nominal share report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.stateCounts = res?.stateCounts || null; + this.loading = false; + }); + } + + /** Live means the account is still a going concern, whether or not it currently holds anything. */ + private isLive(row: NominalRow): boolean { + return row.holdingState === 'HOLDING' || row.holdingState === 'UNPAID' || row.holdingState === 'NIL'; + } + + /** How many accounts the default "live only" view is leaving out, so it can say so. */ + get hiddenByStateCount(): number { + return this.stateFilter === 'LIVE' ? this.rows.filter((r) => !this.isLive(r)).length : 0; + } + + get unpaidRows(): NominalRow[] { + return this.rows.filter((r) => r.holdingState === 'UNPAID'); + } + + get unpaidShares(): number { + return this.unpaidRows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0); + } + + get branchOptions(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort(); + } + + get filteredRows(): NominalRow[] { + const term = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.stateFilter === 'LIVE' ? !this.isLive(r) : this.stateFilter && r.holdingState !== this.stateFilter) { + return false; + } + if (this.branchFilter && r.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && r.productName !== this.productFilter) { + return false; + } + if (this.complianceFilter && r.complianceStatus !== this.complianceFilter) { + return false; + } + if (this.membershipFilter && r.membershipClass !== this.membershipFilter) { + return false; + } + if (!term) { + return true; + } + return [ + r.clientName, + r.accountNo, + r.cifNumber, + r.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + get shownNominalValue(): number { + return this.filteredRows.reduce((sum, r) => sum + Number(r.nominalValue || 0), 0); + } + + get isFiltered(): boolean { + return !!( + this.search.trim() || + this.branchFilter || + this.productFilter || + this.complianceFilter || + this.membershipFilter || + this.stateFilter !== 'LIVE' + ); + } + + stateLabel(state: string): string { + return holdingStateLabel(state); + } + + stateClass(state: string): string { + return holdingStateClass(state); + } + + stateRowClass(state: string): string { + return holdingStateRowClass(state); + } + + stateNote(state: string): string { + return holdingStateNote(state); + } + + complianceLabel(status: string): string { + return COMPLIANCE[status]?.label || status; + } + + complianceClass(status: string): string { + return COMPLIANCE[status]?.cls || 'srep-pill--grey'; + } + + membershipLabel(membership: string): string { + return MEMBERSHIP[membership]?.label || membership; + } + + membershipClass(membership: string): string { + return MEMBERSHIP[membership]?.cls || 'srep-pill--grey'; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.complianceFilter = ''; + this.membershipFilter = ''; + this.stateFilter = 'LIVE'; + } + + openAccount(row: NominalRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'shares-accounts', + row.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + `share-nominal-${this.asOnDate || 'today'}`, + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Account Status', + 'Holding State', + 'Shares Held', + 'Allotted Unpaid', + 'Nominal Required', + 'Against Nominal', + 'Minimum', + 'Maximum', + 'Face Value', + 'Nominal Value', + 'Membership Class', + 'Compliance' + ], + this.filteredRows.map((r) => [ + r.clientName, + r.cifNumber, + r.accountNo, + r.officeName, + r.productName, + this.statusLabel(r.statusEnum), + this.stateLabel(r.holdingState), + r.sharesHeld, + r.unpaidShares, + r.nominalShares, + r.againstNominal, + r.minimumShares, + r.maximumShares, + r.faceValue, + r.nominalValue, + this.membershipLabel(r.membershipClass), + this.complianceLabel(r.complianceStatus) + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html new file mode 100644 index 000000000..caaa721b9 --- /dev/null +++ b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.html @@ -0,0 +1,160 @@ + + +

+
+
+ +
+

Share Payment Certificate

+
Proof of what a member paid for their shares, and against which receipt.
+
+
+
+ +
+
+ +
+
+ +
+ + +
+ @if (accountOptions.length && !selectedAccountId) { +
+ @for (opt of accountOptions; track opt.id) { + + } +
+ } +
+
+ + @if (!selectedAccountId) { +
Search for a member or share account above to issue a payment certificate.
+ } @else if (loading) { +
Loading payment record…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (account) { + +
+
+
{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}
+ @if (institution?.addressLine1 || institution?.address) { +
{{ institution?.addressLine1 || institution?.address }}
+ } +
Share Payment Certificate
+
+ +
+

+ This is to certify that {{ account.clientName }} + @if (account.cifNumber) { + (Membership No. {{ account.cifNumber }}) + } + @if (account.officeName) { + of our {{ account.officeName }} branch + } + has paid the amounts set out below towards + {{ totals?.sharesPaidFor | number }} shares of {{ account.productName }} at ₹{{ + account.unitPrice | number: '1.2-2' + }} + each, held under share account {{ account.accountNo }}. +

+
+ +
+ + + + + + + + + + + + + + @for (p of payments; track p.transactionId) { + + + + + + + + + + } + @for (c of charges; track c.chargeName) { + + + + + + + + } + + + + + + + + +
Payment DateSharesUnit PriceAmount PaidPaid ByReceipt / VoucherBoard Resolution
{{ p.paymentDate }}{{ p.shares | number }}{{ p.unitPrice | number: '1.2-2' }}₹{{ p.amount | number: '1.2-2' }}{{ funding(p.fundingSource) }} + {{ receiptRef(p) }} + @if (p.receiptDate) { +
{{ p.receiptDate }}
+ } +
{{ p.resolutionNumber || '—' }}
———₹{{ c.amountPaid | number: '1.2-2' }}{{ c.chargeName }} (entry fee)
Total paid₹{{ totals?.totalPaid | number: '1.2-2' }}
+
+ + @if (amountInWords) { +
+ Amount in words: {{ amountInWords }} +
+ } + +
+
+
+ Date of issue: {{ today }} +
+
+ Account status: {{ statusLabel(account.statusEnum) }}. Shares currently held: + {{ account.sharesHeld | number }}. +
+
+
+
+
Authorised Signatory
+
+
+
+ } +
diff --git a/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts new file mode 100644 index 000000000..0eba4b455 --- /dev/null +++ b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts @@ -0,0 +1,236 @@ +/** + * 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 { ActivatedRoute } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util'; + +/** + * Share Payment Certificate — proof of what a member paid for their shares, and against which + * receipt. + * + *

+ * Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads + * with amounts, dates and the voucher each one was received on. It is the document a member is asked + * for when the money, not the holding, has to be proved. + * + *

+ * Every tranche is shown rather than one total — a member who paid in instalments paid on several + * dates against several receipts, and a certificate carrying a single figure reconciles to none of + * them. + */ +@Component({ + selector: 'mifosx-share-payment-certificate', + templateUrl: './share-payment-certificate.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class SharePaymentCertificateComponent implements OnInit { + private sharesService = inject(SharesService); + private organizationService = inject(OrganizationService); + private route = inject(ActivatedRoute); + + accountSearch = ''; + accountOptions: any[] = []; + selectedAccountId: number | null = null; + + loading = false; + loadError = ''; + account: any = null; + payments: any[] = []; + charges: any[] = []; + totals: any = null; + + /** The society's own name and address, so the printed certificate is on its letterhead. */ + institution: any = null; + + private searchTerm$ = new Subject(); + + ngOnInit(): void { + this.searchTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => { + this.accountOptions = Array.isArray(res) ? res : res?.pageItems || []; + }); + + this.organizationService + .getInstitutionProfile() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.institution = res)); + + const preselected = this.route.snapshot.queryParamMap.get('shareAccountId'); + if (preselected) { + this.selectedAccountId = Number(preselected); + this.load(); + } + this.searchTerm$.next(''); + } + + onAccountSearch(term: string): void { + this.accountSearch = term; + this.searchTerm$.next(term.trim()); + } + + selectAccount(option: any): void { + this.selectedAccountId = option.id; + this.accountOptions = []; + this.accountSearch = `${option.clientName} — ${option.accountNo}`; + this.load(); + } + + load(): void { + if (!this.selectedAccountId) { + return; + } + this.loading = true; + this.loadError = ''; + this.sharesService + .getSharePaymentCertificate(this.selectedAccountId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The payment certificate could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.account = res?.account || null; + this.payments = res?.payments || []; + this.charges = res?.charges || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + funding(source: string): string { + return fundingLabel(source); + } + + /** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */ + receiptRef(payment: any): string { + return ( + payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—') + ); + } + + /** Amount in words, because a certificate carrying a figure alone is trivially altered. */ + get amountInWords(): string { + const amount = Math.round(Number(this.totals?.totalPaid || 0)); + return amount ? `${this.toWords(amount)} rupees only` : ''; + } + + private toWords(value: number): string { + const ones = [ + '', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + 'eleven', + 'twelve', + 'thirteen', + 'fourteen', + 'fifteen', + 'sixteen', + 'seventeen', + 'eighteen', + 'nineteen' + ]; + const tens = [ + '', + '', + 'twenty', + 'thirty', + 'forty', + 'fifty', + 'sixty', + 'seventy', + 'eighty', + 'ninety' + ]; + + const twoDigits = (n: number): string => + n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`; + + // Indian grouping — a co-operative's certificates read in lakh and crore, not million. + const parts: string[] = []; + const crore = Math.floor(value / 10000000); + const lakh = Math.floor((value % 10000000) / 100000); + const thousand = Math.floor((value % 100000) / 1000); + const hundred = Math.floor((value % 1000) / 100); + const rest = value % 100; + + if (crore) { + parts.push(`${twoDigits(crore)} crore`); + } + if (lakh) { + parts.push(`${twoDigits(lakh)} lakh`); + } + if (thousand) { + parts.push(`${twoDigits(thousand)} thousand`); + } + if (hundred) { + parts.push(`${ones[hundred]} hundred`); + } + if (rest) { + parts.push(twoDigits(rest)); + } + const words = parts.join(' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero'; + } + + get today(): string { + const now = new Date(); + return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`; + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss new file mode 100644 index 000000000..26eabff50 --- /dev/null +++ b/src/app/shares/reports/share-report.shared.scss @@ -0,0 +1,494 @@ +/** + * 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/. + */ + +/** + * Shared table, pill and print styling for the share reports. + * + * Layered on top of certificates.shared.scss, which already supplies the page frame + * (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here. + */ + +$srep-border: #e2e8f0; +$srep-border-soft: #f1f5f9; +$srep-head-bg: #f8fafc; +$srep-text: #0f172a; +$srep-muted: #64748b; + +.srep-table-wrap { + overflow-x: auto; + border: 1px solid $srep-border; + border-radius: 10px; + background: #fff; +} + +.srep-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + + th { + position: sticky; + top: 0; + z-index: 1; + background: $srep-head-bg; + color: $srep-muted; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid $srep-border; + white-space: nowrap; + } + + td { + padding: 0.5rem 0.7rem; + border-bottom: 1px solid $srep-border-soft; + color: $srep-text; + vertical-align: middle; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr.srep-row--click { + cursor: pointer; + + &:hover { + background: #f4f7fb; + } + } + + // A row that is over — closed, rejected, never activated — still belongs on the register, but it + // must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a + // closed account that silently disappears is its own kind of confusion at a counter. + tbody tr.srep-row--muted { + background: #fafbfc; + color: $srep-muted; + + td { + color: $srep-muted; + } + + td:first-child { + box-shadow: inset 3px 0 0 #cbd5e1; + } + } + + // Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing. + tbody tr.srep-row--flag { + background: #fffdf5; + + td:first-child { + box-shadow: inset 3px 0 0 #f59e0b; + } + } + + .srep-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .srep-mono { + font-family: 'Courier New', monospace; + } + + // A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid" + // beneath a held count of nothing. + .srep-sub { + margin-top: 1px; + font-size: 0.68rem; + font-weight: 600; + color: #b45309; + } + + tfoot td { + background: $srep-head-bg; + font-weight: 700; + border-top: 2px solid $srep-border; + border-bottom: none; + } +} + +.srep-pill { + display: inline-block; + padding: 1px 9px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; + + &.srep-pill--green { + background: #ecfdf5; + color: #059669; + border: 1px solid #a7f3d0; + } + + &.srep-pill--amber { + background: #fffbeb; + color: #d97706; + border: 1px solid #fde68a; + } + + &.srep-pill--red { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + } + + &.srep-pill--blue { + background: #eff6ff; + color: #2563eb; + border: 1px solid #bfdbfe; + } + + &.srep-pill--grey { + background: #f1f5f9; + color: #475569; + border: 1px solid #cbd5e1; + } +} + +.srep-state { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem 1rem; + color: $srep-muted; + font-size: 0.85rem; + text-align: center; +} + +.srep-state--error { + color: #dc2626; +} + +/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */ +.srep-scope-note { + margin-top: 0.5rem; + font-size: 0.75rem; + color: $srep-muted; +} + +/* Account picker — results drop under the search box rather than into a dialog, so choosing an + account is one click from typing rather than a modal that has to be dismissed. */ +.srep-picker { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 20; + margin-top: 2px; + max-height: 260px; + overflow-y: auto; + background: #fff; + border: 1px solid $srep-border; + border-radius: 8px; + box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%); +} + +.srep-picker-item { + display: flex; + flex-direction: column; + gap: 1px; + width: 100%; + padding: 0.45rem 0.7rem; + background: none; + border: none; + border-bottom: 1px solid $srep-border-soft; + text-align: left; + cursor: pointer; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f4f7fb; + } +} + +.srep-picker-name { + font-size: 0.82rem; + font-weight: 600; + color: $srep-text; +} + +.srep-picker-meta { + font-size: 0.72rem; + color: $srep-muted; +} + +/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers, + and a chart library for one bar per row would be a dependency for nothing. */ +.srep-bar { + width: 100%; + height: 8px; + background: $srep-border-soft; + border-radius: 999px; + overflow: hidden; +} + +.srep-bar-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb, #60a5fa); + border-radius: 999px; +} + +/* Statement header — the identity block a printed statement has to carry. */ +.srep-stmt-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + margin-bottom: 0.85rem; + background: #f8fafc; + border: 1px solid $srep-border; + border-radius: 10px; +} + +.srep-stmt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.6rem 1.2rem; + flex: 1; +} + +.srep-stmt-l { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $srep-muted; + font-weight: 700; +} + +.srep-stmt-v { + font-size: 0.84rem; + color: $srep-text; + font-weight: 500; +} + +/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a + figure that will not reconcile until someone acts. Loud enough to be read, not an error. */ +.srep-warn { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0.6rem 0; + padding: 0.65rem 0.85rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-left: 4px solid #d97706; + border-radius: 8px; + font-size: 0.82rem; + line-height: 1.55; + color: #92400e; +} + +/* The selected tab, in a screen that has two halves rather than two pages. */ +.srep-tab--active { + background: #eff6ff !important; + border-color: #2563eb !important; + color: #1e40af !important; + font-weight: 700; +} + +/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in + sentences and a signature block, because that is what a member is handed and files. */ +.spc-doc { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + background: #fff; +} + +.spc-letterhead { + text-align: center; + border-bottom: 2px solid $srep-text; + padding-bottom: 0.75rem; + margin-bottom: 1.1rem; +} + +.spc-org { + font-size: 1.15rem; + font-weight: 700; + color: $srep-text; + letter-spacing: 0.02em; +} + +.spc-org-sub { + font-size: 0.78rem; + color: $srep-muted; + margin-top: 2px; +} + +.spc-title { + margin-top: 0.7rem; + font-size: 0.95rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $srep-text; +} + +.spc-body { + font-size: 0.88rem; + line-height: 1.7; + color: $srep-text; + margin-bottom: 1rem; +} + +.spc-words { + margin-top: 0.75rem; + font-size: 0.85rem; + color: $srep-text; +} + +.spc-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + margin-top: 2.5rem; + font-size: 0.82rem; +} + +.spc-sign { + text-align: center; + font-size: 0.8rem; + color: $srep-text; +} + +.spc-sign-line { + width: 170px; + border-top: 1px solid $srep-text; + margin-bottom: 4px; +} + +/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */ +.slab-sheet { + display: grid; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.slab { + border: 1px dashed $srep-border; + border-radius: 6px; + padding: 0.65rem 0.8rem; + min-height: 92px; + font-size: 0.82rem; + line-height: 1.45; + color: $srep-text; + break-inside: avoid; +} + +.slab-name { + font-weight: 700; +} + +.slab-line { + color: $srep-text; +} + +.slab-meta { + margin-top: 3px; + font-size: 0.7rem; + color: $srep-muted; +} + +/* One letter, one page. */ +.slet-page { + border: 1px solid $srep-border; + border-radius: 10px; + padding: 1.5rem 1.75rem; + margin-top: 0.85rem; + background: #fff; + break-after: page; +} + +.slet-meta { + display: flex; + justify-content: space-between; + gap: 1.5rem; + font-size: 0.84rem; + line-height: 1.5; + margin-bottom: 1.1rem; +} + +.slet-date { + white-space: nowrap; + color: $srep-muted; +} + +.slet-subject { + font-weight: 700; + font-size: 0.86rem; + margin-bottom: 0.9rem; +} + +.slet-salutation { + font-size: 0.86rem; + margin-bottom: 0.6rem; +} + +.slet-body { + font-size: 0.86rem; + line-height: 1.75; + white-space: pre-wrap; + color: $srep-text; +} + +.slet-body-input { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid $srep-border; + border-radius: 8px; + font-family: inherit; + font-size: 0.84rem; + line-height: 1.6; + color: $srep-text; + resize: vertical; +} + +@media print { + .cert-head-r, + .cert-filters, + .srep-no-print { + display: none !important; + } + + .slab { + border: 1px solid #999; + } + + .slet-page { + border: none; + padding: 0; + margin: 0; + } + + .spc-doc { + border: none; + padding: 0; + } + + .srep-table-wrap { + overflow: visible; + border: none; + } + + .srep-table th { + position: static; + } +} diff --git a/src/app/shares/reports/share-report.util.ts b/src/app/shares/reports/share-report.util.ts new file mode 100644 index 000000000..4d609ef39 --- /dev/null +++ b/src/app/shares/reports/share-report.util.ts @@ -0,0 +1,185 @@ +/** + * 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/. + */ + +/** + * Shared vocabulary and plumbing for the share reports. + * + * Kept in one place because the reports are read side by side — a member shown as "Active" on the + * balance report and "Approved" on the status report would read as two different things when it is + * one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever + * spreadsheet opens both. + */ + +/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */ +export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Pending Approval', cls: 'srep-pill--amber' }, + 200: { label: 'Approved', cls: 'srep-pill--blue' }, + 300: { label: 'Active', cls: 'srep-pill--green' }, + 500: { label: 'Rejected', cls: 'srep-pill--red' }, + 600: { label: 'Closed', cls: 'srep-pill--grey' } +}; + +/** + * Why a share account's holding reads the way it does — the server's own derivation, so every + * report tells the member the same story about the same account. + * + *

+ * A nil holding is the most over-loaded figure on a share register. A closed account, an active + * account whose money was never collected, and an account redeemed down to nothing all print the + * same "0", and a reader cannot tell them apart. Each state therefore carries its own pill, its own + * row treatment, and a sentence saying what it means — closed and finished rows are dimmed, and the + * one state that still wants chasing is flagged rather than greyed out. + */ +export const SHARE_HOLDING_STATE: { + [key: string]: { label: string; cls: string; row: string; note: string }; +} = { + HOLDING: { + label: 'Holding', + cls: 'srep-pill--green', + row: '', + note: 'Paid-up shares in hand.' + }, + UNPAID: { + label: 'Allotted · unpaid', + cls: 'srep-pill--amber', + row: 'srep-row--flag', + note: 'Shares allotted, but the collection was never applied — so they are in no capital figure.' + }, + NIL: { + label: 'Nil holding', + cls: 'srep-pill--grey', + row: 'srep-row--muted', + note: 'Active and paid for, but redeemed back down to nothing.' + }, + CLOSED: { + label: 'Closed', + cls: 'srep-pill--grey', + row: 'srep-row--muted', + note: 'The account is closed — the nil holding is final and correct.' + }, + NOT_ACTIVE: { + label: 'Not active', + cls: 'srep-pill--blue', + row: 'srep-row--muted', + note: 'Submitted or approved, not yet activated.' + }, + REJECTED: { + label: 'Rejected', + cls: 'srep-pill--red', + row: 'srep-row--muted', + note: 'The application never became an account.' + } +}; + +/** The states in the order a reader thinks about them — live first, finished last. */ +export const SHARE_HOLDING_STATE_ORDER = [ + 'HOLDING', + 'UNPAID', + 'NIL', + 'CLOSED', + 'NOT_ACTIVE', + 'REJECTED' +]; + +export function holdingStateLabel(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.label || state || '—'; +} + +export function holdingStateClass(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.cls || 'srep-pill--grey'; +} + +/** The row treatment: dimmed when the account is over, flagged when it still wants chasing. */ +export function holdingStateRowClass(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.row || ''; +} + +export function holdingStateNote(state: string | null | undefined): string { + return SHARE_HOLDING_STATE[state || '']?.note || ''; +} + +/** m_share_account_transactions.type_enum — only the two that move shares. */ +export const SHARE_TXN_TYPE: { [key: number]: { label: string; cls: string } } = { + 500: { label: 'Purchase', cls: 'srep-pill--green' }, + 600: { label: 'Redemption', cls: 'srep-pill--red' }, + 700: { label: 'Charge Payment', cls: 'srep-pill--grey' } +}; + +/** m_share_account_transactions.status_enum — a tranche's own approval state. */ +export const SHARE_TXN_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Pending', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--green' }, + 400: { label: 'Rejected', cls: 'srep-pill--red' } +}; + +/** How a tranche was paid for; blank on the opening tranche, which the account itself carries. */ +export function fundingLabel(source: string | null | undefined): string { + if (!source) { + return 'Opening'; + } + return source === 'CASH' ? 'Cash Receipt' : source === 'SAVINGS' ? 'Savings Debit' : source; +} + +/** m_share_account_dividend_details.status — where a member's dividend has got to. */ +export const SHARE_DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = { + 100: { label: 'Declared', cls: 'srep-pill--amber' }, + 300: { label: 'Approved', cls: 'srep-pill--blue' }, + 600: { label: 'Paid', cls: 'srep-pill--green' } +}; + +/** Where a transfer stands in maker-checker. */ +export const SHARE_TRANSFER_STATUS: { [key: string]: { label: string; cls: string } } = { + PENDING: { label: 'Pending approval', cls: 'srep-pill--amber' }, + APPROVED: { label: 'Approved', cls: 'srep-pill--green' }, + REJECTED: { label: 'Rejected', cls: 'srep-pill--red' } +}; + +/** + * Fineract's period frequency enum, as a phrase. + * + * Reads "18 months" rather than "18 / 2", which is what the raw pair means to nobody outside the + * schema. An unset period says "None" rather than showing a bare zero that looks like a real term. + */ +export function periodLabel(value: number | null | undefined, frequencyEnum: number | null | undefined): string { + if (!value) { + return 'None'; + } + const unit = { 0: 'day', 1: 'week', 2: 'month', 3: 'year' }[frequencyEnum ?? -1] || 'period'; + return `${value} ${unit}${Number(value) === 1 ? '' : 's'}`; +} + +/** + * Downloads rows as CSV. + * + * Every field is quoted and its own quotes doubled, so a member name carrying a comma — which is + * most of them, once an address or a title creeps in — cannot shift every later column by one. + */ +export function shareReportCsv( + filename: string, + headers: string[], + rows: (string | number | null | undefined)[][] +): void { + const escape = (value: string | number | null | undefined): string => `"${String(value ?? '').replace(/"/g, '""')}"`; + const csv = [ + headers.map(escape).join(','), + ...rows.map((row) => row.map(escape).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 = `${filename}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +/** The browser's own print, against the component's print stylesheet. */ +export function shareReportPrint(): void { + window.print(); +} diff --git a/src/app/shares/reports/share-status-report/share-status-report.component.html b/src/app/shares/reports/share-status-report/share-status-report.component.html new file mode 100644 index 000000000..85fd0ee19 --- /dev/null +++ b/src/app/shares/reports/share-status-report/share-status-report.component.html @@ -0,0 +1,230 @@ + + +

+
+
+ +
+

Share Status Report

+
+ Where every share account stands — by status and share class, with the accounts behind each figure. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+ +
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+ +
+
{{ totals.approvedShares | number }}
+
Shares Allotted
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ } + + @if (loading) { +
Loading share status…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else { + +
+ + + + + + + + + + + + @for (s of statusTotals; track s.statusEnum) { + + + + + + + + } + + + + + + + + + + +
StatusAccountsMembersShares AllottedShare Value
+ {{ statusLabel(s.statusEnum) }} + @if (statusFilter === s.statusEnum) { +  filtered  + } + {{ s.accountCount | number }}{{ s.memberCount | number }}{{ s.shares | number }}₹{{ s.value | number: '1.2-2' }}
All statuses{{ totals?.accountCount | number }}{{ totals?.memberCount | number }}{{ totals?.approvedShares | number }}₹{{ totals?.shareValue | number: '1.2-2' }}
+
+ + + @if (groups.length > 1) { +
+
+ +
+
+
+ + + + + + + + + + + + + + @for (g of groups; track g.productId + '-' + g.statusEnum) { + + + + + + + + + + } + +
Share ProductStatusAccountsMembersSharesPending SharesShare Value
{{ g.productName }} + {{ statusLabel(g.statusEnum) }} + {{ g.accountCount | number }}{{ g.memberCount | number }}{{ g.approvedShares | number }}{{ g.pendingShares | number }}₹{{ g.shareValue | number: '1.2-2' }}
+
+ } + + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (!filteredAccounts.length) { +
No share account matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (a of filteredAccounts; track a.shareAccountId) { + + + + + + + + + + + + + } + +
MemberMembership NoShare A/c NoBranchProductStatusStatus DateSharesPendingShare Value
{{ a.clientName }}{{ a.cifNumber || '—' }}{{ a.accountNo }}{{ a.officeName || '—' }}{{ a.productName }} + {{ statusLabel(a.statusEnum) }} + {{ effectiveDate(a) || '—' }}{{ a.approvedShares | number }}{{ a.pendingShares | number }}₹{{ a.shareValue | number: '1.2-2' }}
+
+ } + } +
diff --git a/src/app/shares/reports/share-status-report/share-status-report.component.ts b/src/app/shares/reports/share-status-report/share-status-report.component.ts new file mode 100644 index 000000000..81baf66c9 --- /dev/null +++ b/src/app/shares/reports/share-status-report/share-status-report.component.ts @@ -0,0 +1,268 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SHARE_ACCOUNT_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface StatusGroup { + statusEnum: number; + productId: number; + productName: string; + accountCount: number; + memberCount: number; + approvedShares: number; + pendingShares: number; + shareValue: number; +} + +interface StatusAccount { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeName: string; + productId: number; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + shareValue: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + rejectedDate: string; + closedDate: string; +} + +/** + * Share Status — where every share account stands, summarised by status and share class, with the + * accounts behind each figure on the same screen. + * + *

+ * Summary and detail together on purpose: the question this report is opened with is almost always + * "how many are pending, and which ones", and splitting that across two screens turns one answer + * into two navigations. Clicking a summary row filters the detail below it rather than leaving. + */ +@Component({ + selector: 'mifosx-share-status-report', + templateUrl: './share-status-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareStatusReportComponent implements OnInit { + private sharesService = inject(SharesService); + private router = inject(Router); + + loading = true; + loadError = ''; + groups: StatusGroup[] = []; + accounts: StatusAccount[] = []; + totals: any = null; + + search = ''; + branchFilter = ''; + productFilter = ''; + /** Set by clicking a summary row; '' means every status. */ + statusFilter: number | '' = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareStatusSummary({}) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The share status report could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.groups = res?.groups || []; + this.accounts = res?.accounts || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get branchOptions(): string[] { + return [...new Set(this.accounts.map((a) => a.officeName).filter(Boolean))].sort(); + } + + get productOptions(): string[] { + return [...new Set(this.accounts.map((a) => a.productName).filter(Boolean))].sort(); + } + + /** Distinct statuses present, in the lifecycle order a reader expects rather than by count. */ + get statusOptions(): number[] { + return [...new Set(this.groups.map((g) => g.statusEnum))].sort((a, b) => a - b); + } + + /** One line per status, products folded together — the shape a board paper wants. */ + get statusTotals(): { + statusEnum: number; + accountCount: number; + memberCount: number; + shares: number; + value: number; + }[] { + const byStatus = new Map< + number, + { statusEnum: number; accountCount: number; memberCount: number; shares: number; value: number } + >(); + this.groups.forEach((g) => { + const entry = byStatus.get(g.statusEnum) || { + statusEnum: g.statusEnum, + accountCount: 0, + memberCount: 0, + shares: 0, + value: 0 + }; + entry.accountCount += Number(g.accountCount || 0); + entry.memberCount += Number(g.memberCount || 0); + entry.shares += Number(g.approvedShares || 0); + entry.value += Number(g.shareValue || 0); + byStatus.set(g.statusEnum, entry); + }); + return [...byStatus.values()].sort((a, b) => a.statusEnum - b.statusEnum); + } + + get filteredAccounts(): StatusAccount[] { + const term = this.search.trim().toLowerCase(); + return this.accounts.filter((a) => { + if (this.statusFilter !== '' && a.statusEnum !== this.statusFilter) { + return false; + } + if (this.branchFilter && a.officeName !== this.branchFilter) { + return false; + } + if (this.productFilter && a.productName !== this.productFilter) { + return false; + } + if (!term) { + return true; + } + return [ + a.clientName, + a.accountNo, + a.cifNumber, + a.productName + ] + .filter(Boolean) + .some((field) => String(field).toLowerCase().includes(term)); + }); + } + + selectStatus(statusEnum: number): void { + this.statusFilter = this.statusFilter === statusEnum ? '' : statusEnum; + } + + statusLabel(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? ''); + } + + statusClass(statusEnum: number): string { + return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey'; + } + + /** The date that put an account into the status it is in — otherwise every row shows a blank. */ + effectiveDate(account: StatusAccount): string { + switch (account.statusEnum) { + case 600: + return account.closedDate; + case 500: + return account.rejectedDate; + case 300: + return account.activatedDate; + case 200: + return account.approvedDate; + default: + return account.submittedDate; + } + } + + clearFilters(): void { + this.search = ''; + this.branchFilter = ''; + this.productFilter = ''; + this.statusFilter = ''; + } + + openAccount(account: StatusAccount): void { + this.router.navigate([ + '/clients', + account.clientId, + 'shares-accounts', + account.shareAccountId, + 'general' + ]); + } + + exportCsv(): void { + shareReportCsv( + 'share-status-report', + [ + 'Member', + 'Membership No', + 'Share A/c No', + 'Branch', + 'Product', + 'Status', + 'Status Date', + 'Approved Shares', + 'Pending Shares', + 'Share Value' + ], + this.filteredAccounts.map((a) => [ + a.clientName, + a.cifNumber, + a.accountNo, + a.officeName, + a.productName, + this.statusLabel(a.statusEnum), + this.effectiveDate(a), + a.approvedShares, + a.pendingShares, + a.shareValue + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-transfer/share-transfer.component.html b/src/app/shares/reports/share-transfer/share-transfer.component.html new file mode 100644 index 000000000..b73109ca1 --- /dev/null +++ b/src/app/shares/reports/share-transfer/share-transfer.component.html @@ -0,0 +1,282 @@ + + +

+
+
+ +
+

Share Transfer

+
+ Moving shares from one member to another, under board resolution — and the register of every transfer made. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totals.transferCount | number }}
+
Transfers
+
+
+
+ +
+
{{ totals.pendingCount | number }}
+
Awaiting Approval
+
+
+
+ +
+
{{ totals.sharesTransferred | number }}
+
Shares Transferred
+
+
+
+ +
+
₹{{ totals.valueTransferred | number: '1.2-2' }}
+
Value Transferred
+
+
+
+ } + + +
+
+ + +
+
+ + +
+
+ + @if (tab === 'new') { + +
+
+
+ +
+ + +
+ @if (fromOptions.length && !fromAccount) { +
+ @for (opt of fromOptions; track opt.id) { + + } +
+ } +
+ +
+ +
+ + +
+ @if (toOptions.length && !toAccount) { +
+ @for (opt of toOptions; track opt.id) { + + } +
+ } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + @if (fromAccount && toAccount && shares) { +
+ {{ shares | number }} shares of {{ fromAccount.productName }} move from + {{ fromAccount.clientName }} ({{ fromAccount.accountNo }}) to {{ toAccount.clientName }} ({{ + toAccount.accountNo + }}). Share capital does not change — only the register does. Nothing moves until a checker approves it. +
+ } +
+ + @if (submitError) { +
{{ submitError }}
+ } + @if (submitSuccess) { +
{{ submitSuccess }}
+ } + } + + +
+
+ +
+ + +
+
+
+ + +
+
+ + @if (loading) { +
Loading the transfer register…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!rows.length) { +
No share transfer has been recorded yet.
+ } @else if (!filteredRows.length) { +
No transfer matches the filters applied.
+ } @else { +
+ + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.transferId) { + + + + + + + + + + + + + } + +
ReferenceDateFromToProductSharesValueBoard ResolutionStatusAction
{{ row.transferReference }}{{ row.transferDate }} + {{ row.fromClientName }} +
{{ row.fromAccountNo }}
+
+ {{ row.toClientName }} +
{{ row.toAccountNo }}
+
{{ row.productName }}{{ row.shares | number }}₹{{ row.transferAmount | number: '1.2-2' }} + {{ row.resolutionNumber || '—' }} + @if (row.meetingDate) { +
{{ row.meetingDate }}
+ } +
+ {{ statusLabel(row.status) }} + @if (row.status === 'REJECTED' && row.rejectionReason) { +
{{ row.rejectionReason }}
+ } + @if (row.status !== 'PENDING' && row.checkerName) { +
by {{ row.checkerName }}
+ } +
+ @if (row.status === 'PENDING') { + + + } @else { + {{ row.actionedOn || '—' }} + } +
+
+ } +
diff --git a/src/app/shares/reports/share-transfer/share-transfer.component.ts b/src/app/shares/reports/share-transfer/share-transfer.component.ts new file mode 100644 index 000000000..154d8aab2 --- /dev/null +++ b/src/app/shares/reports/share-transfer/share-transfer.component.ts @@ -0,0 +1,339 @@ +/** + * 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 { Subject, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SHARE_TRANSFER_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util'; + +/** + * Share Transfer — moving shares from one member to another, and the register of every transfer + * made. + * + *

+ * No money reaches the society and its share capital does not change: what changes is the register, + * one member's holding down and another's up. The society's part is to approve it, so the screen is + * a request form and a queue rather than an instant action — a transfer carries voting rights with + * it and is not one clerk's decision. + * + *

+ * Every rule is enforced by the server at submission and again at approval. Nothing is validated + * only here: holdings move while a request waits, and a browser that checked once at 10am would + * happily submit something impossible by noon. + */ +@Component({ + selector: 'mifosx-share-transfer', + templateUrl: './share-transfer.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareTransferComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + /** Which half of the screen is showing: raise a transfer, or work the register. */ + tab: 'new' | 'register' = 'register'; + + // ── Register ── + loading = true; + loadError = ''; + rows: any[] = []; + totals: any = null; + statusFilter = 'ALL'; + search = ''; + actioningId: number | null = null; + + // ── New transfer ── + fromSearch = ''; + toSearch = ''; + fromOptions: any[] = []; + toOptions: any[] = []; + fromAccount: any = null; + toAccount: any = null; + shares: number | null = null; + transferDate = ''; + resolutionNumber = ''; + meetingDate = ''; + reason = ''; + submitting = false; + submitError = ''; + submitSuccess = ''; + + private fromTerm$ = new Subject(); + private toTerm$ = new Subject(); + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.transferDate = this.toIso(businessDate); + + this.fromTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => (this.fromOptions = Array.isArray(res) ? res : res?.pageItems || [])); + + this.toTerm$ + .pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])))) + ) + .subscribe((res: any) => (this.toOptions = Array.isArray(res) ? res : res?.pageItems || [])); + + this.loadRegister(); + } + + // ── Register ────────────────────────────────────────────────────────── + + loadRegister(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareTransfers(this.statusFilter === 'ALL' ? undefined : this.statusFilter) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer register could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.rows = res?.rows || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get filteredRows(): any[] { + const term = this.search.trim().toLowerCase(); + if (!term) { + return this.rows; + } + return this.rows.filter((r) => + [ + r.fromClientName, + r.toClientName, + r.fromAccountNo, + r.toAccountNo, + r.transferReference, + r.resolutionNumber + ] + .filter(Boolean) + .some((field: any) => String(field).toLowerCase().includes(term)) + ); + } + + approve(row: any): void { + this.actioningId = row.transferId; + this.loadError = ''; + this.sharesService + .approveShareTransfer(row.transferId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer could not be approved.'); + this.actioningId = null; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioningId = null; + if (res) { + this.loadRegister(); + } + }); + } + + reject(row: any): void { + this.actioningId = row.transferId; + this.loadError = ''; + this.sharesService + .rejectShareTransfer(row.transferId) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The transfer could not be rejected.'); + this.actioningId = null; + return of(null); + }) + ) + .subscribe((res: any) => { + this.actioningId = null; + if (res) { + this.loadRegister(); + } + }); + } + + statusLabel(status: string): string { + return SHARE_TRANSFER_STATUS[status]?.label || status; + } + + statusClass(status: string): string { + return SHARE_TRANSFER_STATUS[status]?.cls || 'srep-pill--grey'; + } + + // ── New transfer ────────────────────────────────────────────────────── + + onFromSearch(term: string): void { + this.fromSearch = term; + this.fromAccount = null; + this.fromTerm$.next(term.trim()); + } + + onToSearch(term: string): void { + this.toSearch = term; + this.toAccount = null; + this.toTerm$.next(term.trim()); + } + + pickFrom(option: any): void { + this.fromAccount = option; + this.fromOptions = []; + this.fromSearch = `${option.clientName} — ${option.accountNo}`; + } + + pickTo(option: any): void { + this.toAccount = option; + this.toOptions = []; + this.toSearch = `${option.clientName} — ${option.accountNo}`; + } + + /** + * Only what the browser can honestly know: both sides chosen, a positive number of shares, and the + * resolution captured. Everything about holdings and class limits is the server's to judge, since + * only it knows what the register says right now. + */ + get canSubmit(): boolean { + return !!( + this.fromAccount && + this.toAccount && + this.shares && + this.shares > 0 && + this.transferDate && + this.resolutionNumber.trim() && + this.meetingDate && + !this.submitting + ); + } + + submit(): void { + if (!this.canSubmit) { + return; + } + this.submitting = true; + this.submitError = ''; + this.submitSuccess = ''; + this.sharesService + .submitShareTransfer({ + fromShareAccountId: this.fromAccount.id, + toShareAccountId: this.toAccount.id, + shares: this.shares, + transferDate: this.transferDate, + resolutionNumber: this.resolutionNumber.trim(), + meetingDate: this.meetingDate, + reason: this.reason.trim() || null + }) + .pipe( + catchError((err) => { + this.submitError = this.readError(err, 'The transfer could not be submitted.'); + this.submitting = false; + return of(null); + }) + ) + .subscribe((res: any) => { + this.submitting = false; + if (res) { + this.submitSuccess = res.message || `Transfer ${res.transferReference} sent for approval.`; + this.resetForm(); + this.loadRegister(); + } + }); + } + + resetForm(): void { + this.fromSearch = ''; + this.toSearch = ''; + this.fromAccount = null; + this.toAccount = null; + this.shares = null; + this.resolutionNumber = ''; + this.meetingDate = ''; + this.reason = ''; + this.submitError = ''; + } + + exportCsv(): void { + shareReportCsv( + 'share-transfer-register', + [ + 'Reference', + 'Date', + 'From Member', + 'From A/c No', + 'To Member', + 'To A/c No', + 'Product', + 'Shares', + 'Unit Price', + 'Value', + 'Board Resolution', + 'Meeting Date', + 'Status', + 'Raised By', + 'Actioned By' + ], + this.filteredRows.map((r) => [ + r.transferReference, + r.transferDate, + r.fromClientName, + r.fromAccountNo, + r.toClientName, + r.toAccountNo, + r.productName, + r.shares, + r.unitPrice, + r.transferAmount, + r.resolutionNumber, + r.meetingDate, + this.statusLabel(r.status), + r.makerName, + r.checkerName + ]) + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/reports/share-wise-report/share-wise-report.component.html b/src/app/shares/reports/share-wise-report/share-wise-report.component.html new file mode 100644 index 000000000..4f8db52f4 --- /dev/null +++ b/src/app/shares/reports/share-wise-report/share-wise-report.component.html @@ -0,0 +1,165 @@ + + +

+
+
+ +
+

Share Wise Report

+
+ How the society's share capital is spread — members per holding band, and the split by share class. +
+
+
+
+ + +
+
+ + @if (totals) { +
+
+ +
+
{{ totalMembers | number }}
+
Shareholding Members
+
+
+
+ +
+
{{ totals.shares | number }}
+
Shares Held
+
+
+
+ +
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ +
+
{{ byProduct.length | number }}
+
Share Classes
+
+
+
+ } + +
+
+ + +
+
+ + @if (loading) { +
Loading the shareholding pattern…
+ } @else if (loadError) { +
{{ loadError }}
+ } @else if (!totals?.shares) { +
No member held share capital as on {{ asOnDate }}.
+ } @else { + +
+ + + + + + + + + + + + + + @for (band of bands; track band.band) { + + + + + + + + + + } + + + + + + + + + + + + +
Holding Band (shares)MembersDistributionAccountsSharesShare Value% of Capital
{{ band.band }}{{ band.memberCount | number }} +
+
+
+
{{ band.accountCount | number }}{{ band.shares | number }}₹{{ band.shareValue | number: '1.2-2' }}{{ band.capitalShare | number: '1.2-2' }}%
All bands{{ totalMembers | number }}{{ totals.shares | number }}₹{{ totals.shareValue | number: '1.2-2' }}100.00%
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + @for (row of byProduct; track row.productId) { + + + + + + + + + } + + + + + + + + + + + +
Share ProductMembersAccountsSharesShare Value% of Capital
{{ row.productName }}{{ row.memberCount | number }}{{ row.accountCount | number }}{{ row.shares | number }}₹{{ row.shareValue | number: '1.2-2' }}{{ productShare(row) | number: '1.2-2' }}%
All classes{{ totals.shares | number }}₹{{ totals.shareValue | number: '1.2-2' }}100.00%
+
+ } +
diff --git a/src/app/shares/reports/share-wise-report/share-wise-report.component.ts b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts new file mode 100644 index 000000000..3620b50bd --- /dev/null +++ b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts @@ -0,0 +1,167 @@ +/** + * 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 { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { SharesService } from '../../shares.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { shareReportCsv, shareReportPrint } from '../share-report.util'; + +interface BandRow { + band: string; + bandOrder: number; + accountCount: number; + memberCount: number; + shares: number; + shareValue: number; + /** This band's percentage of total share capital, computed server-side against the true total. */ + capitalShare: number; +} + +interface ProductRow { + productId: number; + productName: string; + accountCount: number; + memberCount: number; + shares: number; + shareValue: number; +} + +/** + * Share Wise Report — the shareholding pattern: how the society's capital is spread across its + * members, and how it splits by share class. + * + *

+ * Concentration is what this answers. A total and a member count cannot show whether three members + * hold half the capital, and that is the number a board and the Registrar both ask for. + * + *

+ * No client-side filters beyond the date and scope the server already applied: a distribution that + * can be narrowed in the browser stops being a distribution — the percentages would still be of the + * whole while the bands shown were of a part. + */ +@Component({ + selector: 'mifosx-share-wise-report', + templateUrl: './share-wise-report.component.html', + styleUrls: [ + '../../../letter-mgmt/certificates/certificates.shared.scss', + '../share-report.shared.scss' + ], + imports: [ + CommonModule, + FormsModule, + FaIconComponent + ] +}) +export class ShareWiseReportComponent implements OnInit { + private sharesService = inject(SharesService); + private settingsService = inject(SettingsService); + + loading = true; + loadError = ''; + bands: BandRow[] = []; + byProduct: ProductRow[] = []; + totals: any = null; + + asOnDate = ''; + + ngOnInit(): void { + const businessDate: Date = this.settingsService.businessDate || new Date(); + this.asOnDate = this.toIso(businessDate); + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.sharesService + .getShareWiseReport({ asOnDate: this.asOnDate }) + .pipe( + catchError((err) => { + this.loadError = this.readError(err, 'The shareholding pattern could not be loaded.'); + return of(null); + }) + ) + .subscribe((res: any) => { + this.bands = res?.bands || []; + this.byProduct = res?.byProduct || []; + this.totals = res?.totals || null; + this.loading = false; + }); + } + + get totalMembers(): number { + return this.bands.reduce((sum, b) => sum + Number(b.memberCount || 0), 0); + } + + /** A product's share of total capital — the same reading the bands give, by class instead. */ + productShare(row: ProductRow): number { + const total = Number(this.totals?.shareValue || 0); + return total ? (Number(row.shareValue || 0) * 100) / total : 0; + } + + /** Bar width for the inline distribution graphic; the largest band fills the cell. */ + barWidth(row: BandRow): number { + const largest = Math.max(...this.bands.map((b) => Number(b.memberCount || 0)), 1); + return (Number(row.memberCount || 0) * 100) / largest; + } + + exportCsv(): void { + shareReportCsv( + `share-wise-${this.asOnDate || 'today'}`, + [ + 'Holding Band', + 'Accounts', + 'Members', + 'Shares', + 'Share Value', + '% of Capital' + ], + [ + ...this.bands.map((b) => [ + b.band, + b.accountCount, + b.memberCount, + b.shares, + b.shareValue, + b.capitalShare + ]), + [ + 'TOTAL', + '', + this.totalMembers, + this.totals?.shares ?? '', + this.totals?.shareValue ?? '', + 100 + ] + ] + ); + } + + print(): void { + shareReportPrint(); + } + + private toIso(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private readError(err: any, fallback: string): string { + return ( + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + err?.error?.developerMessage || + fallback + ); + } +} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index 4e7601cb0..e4b31cf58 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -208,20 +208,20 @@

+ + @if (unattributed.length) { +
+
+ person_add + Unattached Share Collection Receipts — Attach to This Tranche +
+
+ + + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + @if (voucherError) {
error_outline diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 80603efdc..fdb52947f 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -481,6 +481,69 @@ mat-form-field { overflow-x: auto; } +/* Unattached share-collection receipts. Set apart in amber so it never reads as this member's own + settled list above: these belong to nobody until somebody attaches one. */ +.unattributed-section { + margin: 0 0 0.75rem; + padding: 0.65rem; + border: 1px solid $warning-border; + border-radius: 8px; + background: $warning-bg; + + .section-title { + border-bottom: none; + margin-bottom: 0.5rem; + padding-bottom: 0; + + &.text-amber { + color: $warning; + + .sec-icon { + color: $warning; + } + } + } + + .unattributed-table { + background: $bg-surface; + + .col-attach { + width: 96px; + } + } + + .attach-btn { + height: 28px; + line-height: 28px; + font-size: 0.75rem; + padding: 0 0.65rem; + } + + // A row already linked to the member whose screen this is -- the same voucher is also sitting, + // pre-loaded and tickable, in the Vouchers/Receipts table below. This is not a duplicate to + // clear up; it is the same receipt shown two ways for two different purposes (find it here, + // select it there), so the row is called out instead of removed. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; + } +} + .receipt-table { width: 100%; border-collapse: separate; diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index 8922da149..98e09d124 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -100,6 +100,24 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; voucherError = ''; + /** Voucher / Receipt No shown in the "Find Specific Voucher" box. Mirrors whichever receipt is + * ticked below (see {@link toggleVoucher}) so a tick is visibly confirmed up there too, and + * carries whatever the counter has typed in when they search by hand instead. */ + voucherNo = ''; + + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + * A receipt paid straight into a GL account names no member of its own — a GL account is nobody's + * account — so it is never attributed automatically, and never filtered by this member's client id + * (the cashier's typed description is free text and can easily be wrong; nothing here is trusted + * to match a member by parsing it). Listing it is not attribution: it only makes the voucher + * findable, so a counter that tagged a receipt "Share Collection" moments ago is not told there is + * nothing to fund this tranche with. Attaching it below is still a deliberate click against the + * member on screen. + */ + unattributed: any[] = []; + /** GL account the deposit was posted to — required, and always picked by hand. Never resolved * from a share product's accounting setup, so a broken or missing product mapping can never * make a genuine voucher unreachable. */ @@ -242,6 +260,7 @@ export class ApplySharesComponent implements OnInit { this.vouchers = []; this.selectedVoucherIds = []; this.voucherError = ''; + this.voucherNo = ''; this.errorMsg = ''; this.savingsEligibility = null; if (mode === 'SAVINGS') { @@ -266,8 +285,9 @@ export class ApplySharesComponent implements OnInit { /** * Loads the member's unapplied Share Sundry receipts so they can be ticked off. * - * A single receipt is ticked for them: with only one candidate there is nothing to choose, and - * making the clerk tick it adds a step that can only be got wrong by forgetting it. + * Deliberately nothing ticked, not even when a single receipt is listed — which receipt funds the + * tranche is a choice the person applying it makes, not one they inherit from a pre-ticked radio + * they never looked at. */ private loadVouchers(): void { this.loadingVouchers = true; @@ -286,16 +306,49 @@ export class ApplySharesComponent implements OnInit { return; } this.vouchers = res.unappliedCollections || []; - if (this.vouchers.length === 1) { - this.selectedVoucherIds = [this.vouchers[0].id]; - } }); + + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + } + + /** + * Attaches one of the unattributed share-collection receipts to this member's tranche. + * + * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * sitting in this list is either a fresh cashier deposit (which does carry its own GL account) or + * an existing, already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), + * whose GL account can genuinely come back blank — its subquery reads the posting the ORIGINAL + * adoption made, which a clerk-disbursement-funded receipt does not always leave behind cleanly. + * Either way the backend needs no GL account to resolve a voucher it can already find by number, so + * a blank one here must never block the click the way it rightly blocks a hand-typed search with + * nothing chosen yet. + */ + attachUnattributedVoucher(row: any): void { + if (this.lookingUpVoucher || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); } isVoucherSelected(id: number): boolean { return this.selectedVoucherIds.includes(id); } + /** + * Whether an unattributed-list row is already linked to the member this screen belongs to. + * + * Such a row also appears, pre-loaded and ready to tick, in the Vouchers/Receipts table below — + * that is not a bug to hide, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.sharesAccountData.clientId); + } + /** * One receipt funds one tranche — no combining instalments here (unlike the first-application * Apply Collection dialog, which still allows several). Picking a different receipt replaces the @@ -305,15 +358,18 @@ export class ApplySharesComponent implements OnInit { const wasSelected = this.isVoucherSelected(id); this.selectedVoucherIds = wasSelected ? [] : [id]; this.voucherError = ''; - // The GL Account picker above follows whichever receipt is ticked — its own actual GL account - // (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt - // that plainly does have one is already chosen. - if (!wasSelected) { - const voucher = this.vouchers.find((v) => v.id === id); - if (voucher?.gl_code) { - this.glAccountNo = voucher.gl_code; - } + if (wasSelected) { + this.voucherNo = ''; + return; } + // The GL Account and Find Specific Voucher fields above follow whichever receipt is ticked -- + // their own actual GL account and voucher number (see gl_code / voucher_no on the row), not left + // showing an unrelated value or blank once a receipt that plainly has them is already chosen. + const voucher = this.vouchers.find((v) => v.id === id); + if (voucher?.gl_code) { + this.glAccountNo = voucher.gl_code; + } + this.voucherNo = voucher?.voucher_no || voucher?.receipt_no || ''; } /** @@ -330,24 +386,40 @@ export class ApplySharesComponent implements OnInit { * Resolves a voucher number the member quotes and adds it to the list. * * Kept alongside the tick list rather than replacing it: the member usually quotes the number on - * the slip in their hand, which is faster than finding it among their receipts. The lookup is - * deliberately not restricted to this member, so a voucher belonging to somebody else is - * surfaced here as a plain mismatch rather than silently ignored. + * the slip in their hand, which is faster than finding it among their receipts. The lookup is not + * restricted to this member — any receipt found this way can be attached here, whichever member it + * was originally recorded against. */ - lookupVoucher(voucherNo: string): void { - const trimmed = (voucherNo || '').trim(); + lookupVoucher(): void { + const trimmed = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributedVoucher}, which + // already knows exactly which receipt it means and never has this ambiguity. if (!trimmed || !glAccountNo || this.lookingUpVoucher) { return; } + this.resolveVoucher(trimmed, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the list, selected. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributedVoucher} (a click on a row + * already on screen, whose GL account can legitimately be blank) — the actual lookup and its + * bookkeeping afterwards are identical either way. + */ + private resolveVoucher(voucherNo: string, glAccountNo: string): void { this.lookingUpVoucher = true; this.voucherError = ''; this.cashierService - .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) + .getShareAdvanceByVoucher(voucherNo, glAccountNo, this.sharesAccountData.clientId) .pipe( catchError((err: any) => { - this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); + this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`); this.lookingUpVoucher = false; return of(null); }) @@ -357,10 +429,8 @@ export class ApplySharesComponent implements OnInit { if (!res) { return; } - if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) { - this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; - return; - } + // No client-match check here by deliberate choice: any receipt can be attached to any + // share account, regardless of which member it was originally recorded against. // Replaces the selection rather than adding to it — one receipt funds one tranche here, so // finding a voucher by number is a different way to pick THE receipt, not a second one. this.vouchers = [ @@ -368,6 +438,13 @@ export class ApplySharesComponent implements OnInit { ...this.vouchers.filter((v) => v.id !== res.id) ]; this.selectedVoucherIds = [res.id]; + this.voucherNo = res.voucher_no || res.receipt_no || voucherNo; + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucherNo); }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 756d2d39e..12a30dadf 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -6,103 +6,94 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

Apply Share Advance Collection

- - -

- Tick the receipts that make up this payment to settle share account {{ data.accountNo }}, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in - instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income. -

- - - @if (requirement) { -
- - Required: {{ requirement.shareAmount | number: '1.2-2' }} (Shares) - @if (requirement.chargeAmount > 0) { - + {{ requirement.chargeAmount | number: '1.2-2' }} (Entry Fee) = - {{ requirement.required | number: '1.2-2' }} Total - } - - @if (selectedIds.length > 0) { - - @if (shortfall === 0) { - Share receipts covered - } @else { - {{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected — - {{ shortfall | number: '1.2-2' }} short - } - - } +
+ +
+
+ account_balance_wallet
- } - - -
- -
- -
- -
- - +
+

Apply Share Advance Collection

+

+ Settling share account {{ data.accountNo }} +

+
- - @if (requirement && requirement.chargeAmount > 0) { -
- + + +
+ + + Tick the receipts that make up this payment to settle share account {{ data.accountNo }}. Multiple receipts can be selected for instalment payments. Click + visibility to view full receipt popup details. + +
- @if (chargeReceipt) { -
- {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }} - - {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} - — {{ chargeReceipt.amount | number: '1.2-2' }} - @if (chargeShortfall > 0) { - (short by {{ chargeShortfall | number: '1.2-2' }}) - } @else { - — entry fee verified - } - - + + @if (requirement) { +
+
+ calculate + Tranche Requirement
- } @else { -
+ +
+
+ Shares Value + {{ requirement.shareAmount | number: '1.2-2' }} +
+ + @if (requirement.chargeAmount > 0) { + + +
+ Entry Fee + {{ requirement.chargeAmount | number: '1.2-2' }} +
+ } + + = +
+ Total Required + {{ requirement.required | number: '1.2-2' }} +
+
+ + @if (selectedIds.length > 0) { +
+ @if (shortfall === 0) { + + check_circle Share receipts fully covered + + } @else { + + warning {{ selectedTotal | number: '1.2-2' }} of + {{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short + + } +
+ } +
+ } + + +
+ + +
+
+
-
- - -
- -
- Leave this blank if the fee was already paid as part of the share receipt above. -
- } -
- } - - @if (errorMsg) { -
- error_outline - {{ errorMsg }} -
- } - - @if (loading) { -
- - Loading receipts… -
- } @else if (receipts.length === 0) { -
- info_outline -
- No unapplied receipt for this member today. -
- If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from - Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the - voucher appears here. +
+ +
+ + +
- } @else { - - - - - - - - - - - - - @for (r of receipts; track r.id) { - - - - - - - - + + + @if (requirement && requirement.chargeAmount > 0) { +
+ + + @if (chargeReceipt) { +
+ {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }} + + {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} + — {{ chargeReceipt.amount | number: '1.2-2' }} + @if (chargeShortfall > 0) { + (short by {{ chargeShortfall | number: '1.2-2' }}) + } @else { + — Entry fee verified + } + + +
+ } @else { +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ Leave this blank if the entry fee was already paid as part of the main share receipt above. +
} -
- - @if (selectedIds.length > 0) { - - - - - - - + + } + + + @if (unattributed.length) { +
+ +
+
Voucher / ReceiptDateDescriptionCollected ByAmount
- - - {{ r.voucher_no || r.receipt_no }}{{ r.collection_date }}{{ r.note || '—' }}{{ r.collected_by_name || '—' }}{{ r.amount | number: '1.2-2' }}
- {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected - {{ selectedTotal | number: '1.2-2' }}
+ + + + + + + + + + + + @for (u of unattributed; track u.id) { + + + + + + + + + } + +
Voucher NoDateDeposited ToDescriptionAmountAction
+ {{ u.voucherNo }} + @if (isOwnClientVoucher(u)) { + This member's receipt + } + {{ u.txnDate }}{{ u.glAccountNo }} — {{ u.glAccountName }}{{ u.description || '—' }}{{ u.amount | number: '1.2-2' }} + +
+
+
+ } + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + +
+ @if (loading) { +
+ + Loading member receipts… +
+ } @else if (receipts.length === 0) { +
+ receipt_long +
+ No unapplied receipt found for this member today. +

+ If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at + Cashier › Transaction Entry into Share Sundry GL. +

+
+
+ } @else { +
+ +
+
+ receipt + Member Unapplied Receipts + {{ receipts.length }} +
+ + @if (receipts.length > 1) { +
+ +
+ } +
+ + +
+ + + + + + + + + + + + + + @for (r of receipts; track r.id) { + + + + + + + + + + } + +
+ @if (receipts.length > 1) { + + } @else { + # + } + Voucher / Receipt NoDateDescriptionCollected ByAmountDetail
+ + + {{ r.voucher_no || r.receipt_no }} + {{ r.collection_date }}{{ r.note || 'Share Collection' }} + + person_outline + {{ r.collected_by_name || 'System' }} + + + {{ r.amount | number: '1.2-2' }} + + +
+
+ + + +
} - - -
- Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt - above. Anything paid over that is credited to the member's savings account.
- } - + - - - - + + + + + +
+ + +@if (viewingReceiptDetail) { + +} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index b7546ba4a..361dd3acc 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -6,232 +6,976 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -$primary: #1a3a6b; -$border: #d8e0ec; -$muted: #6b7a90; -$danger: #b3261e; -$success: #1e7d3c; +// Modern Color Tokens +$primary: #1e3a8a; +$primary-light: #eff6ff; +$primary-border: #bfdbfe; +$accent-blue: #2563eb; +$text-main: #0f172a; +$text-muted: #64748b; +$border-color: #e2e8f0; +$border-light: #f1f5f9; +$bg-surface: #fff; +$bg-slate: #f8fafc; -.intro { - font-size: 13px; - color: $muted; - margin: 0 0 12px; +$success: #059669; +$success-bg: #ecfdf5; +$success-border: #a7f3d0; + +$warning: #d97706; +$warning-bg: #fffbeb; +$warning-border: #fde68a; + +$danger: #dc2626; +$danger-bg: #fef2f2; +$danger-border: #fecaca; + +:host { + display: block; + width: 100%; } -.requirement-summary { +.dialog-wrapper { + margin: -24px; display: flex; flex-direction: column; - gap: 4px; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - color: $primary; - margin: 0 0 14px; + background: #fff; + border-radius: 16px; + overflow: hidden; } -.requirement-progress { - font-size: 12px; - font-weight: 500; - color: $danger; +/* Header Banner - Spacious & Premium */ +.dialog-header-banner { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.1rem 1.5rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; - &.covered { - color: $success; + .header-icon-box { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + background: rgb(255 255 255 / 20%); + border-radius: 10px; + backdrop-filter: blur(4px); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: #fff; + } + } + + .header-text { + flex: 1; + + h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; + } + + p { + margin: 2px 0 0; + font-size: 0.85rem; + color: rgb(255 255 255 / 88%); + } + } + + .close-header-btn { + color: #fff; + width: 36px; + height: 36px; + line-height: 36px; + border-radius: 8px; + + &:hover { + background: rgb(255 255 255 / 15%); + } } } -.voucher-section { - margin: 0 0 14px; +.dialog-body { + padding: 1.25rem 1.5rem !important; + max-height: 76vh; + overflow-y: auto; + box-sizing: border-box; + + /* Custom Scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 999px; + } } -.voucher-section-label { - font-size: 11px; +/* Intro Callout */ +.dialog-intro-banner { + display: flex; + align-items: flex-start; + gap: 0.65rem; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 0.75rem 1rem; + font-size: 0.84rem; + color: #0369a1; + margin-bottom: 1rem; + + .banner-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #0284c7; + margin-top: 1px; + flex-shrink: 0; + } + + .inline-icon { + font-size: 15px; + width: 15px; + height: 15px; + vertical-align: middle; + } +} + +/* Requirement Summary Stat Card */ +.requirement-summary-card { + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 12px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + + .req-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.65rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid $border-light; + + .req-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } + + .req-metrics { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + + .metric-item { + display: flex; + flex-direction: column; + + .m-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .m-val { + font-size: 0.98rem; + color: $text-main; + } + + &.highlight-total { + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid $primary-border; + border-radius: 8px; + padding: 0.35rem 0.85rem; + + .m-label { + color: #1e40af; + } + + .m-val { + color: #1e3a8a; + font-size: 1.1rem; + } + } + } + + .metric-plus, + .metric-equals { + font-size: 1.1rem; + font-weight: 700; + color: $text-muted; + } + } + + .req-status-bar { + border-top: 1px solid $border-light; + padding-top: 0.5rem; + margin-top: 0.5rem; + } +} + +/* Search Sections */ +.search-section { + margin-bottom: 1rem; + + .section-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; + font-weight: 700; + color: $text-main; + margin-bottom: 0.5rem; + + .sec-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + } +} + +/* Lookup Toolbar - Wide Layout */ +.lookup-toolbar { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 1rem; + background: $bg-slate; + padding: 0.85rem 1rem; + border-radius: 10px; + border: 1px solid $border-color; + + @media (width <= 680px) { + grid-template-columns: 1fr; + } +} + +.toolbar-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.4px; - color: $muted; - margin: 0 0 6px; + letter-spacing: 0.04em; + color: $text-muted; + margin-bottom: 5px; + + .mini-icon { + font-size: 15px; + width: 15px; + height: 15px; + } +} + +.custom-select { + width: 100%; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } +} + +.lookup-input-group { + display: flex; + gap: 0.5rem; + + .lookup-input { + flex: 1; + height: 38px; + padding: 0 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 0.88rem; + color: $text-main; + background: #fff; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $accent-blue; + box-shadow: 0 0 0 3px rgb(37 99 235 / 10%); + } + } + + .find-btn { + height: 38px; + border-radius: 8px; + padding: 0 1rem; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.85rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } } .charge-receipt-found { display: flex; align-items: center; - gap: 8px; - background: #eefaf1; - border: 1px solid #bfe6cb; - border-radius: 6px; - padding: 8px 10px; - font-size: 13px; + gap: 0.75rem; + background: $success-bg; + border: 1px solid $success-border; + border-radius: 10px; + padding: 0.65rem 1rem; + font-size: 0.85rem; color: $success; &.short { - background: #fdecea; - border-color: #f5c6c2; + background: $danger-bg; + border-color: $danger-border; color: $danger; } mat-icon { + font-size: 20px; + width: 20px; + height: 20px; flex-shrink: 0; } -} -.charge-receipt-detail { - flex: 1; - min-width: 0; + .change-btn { + margin-left: auto; + font-size: 0.8rem; + height: 32px; + line-height: 32px; + padding: 0 0.85rem; + } } .charge-receipt-hint { - font-size: 11px; - color: $muted; + font-size: 0.76rem; + color: $text-muted; margin-top: 4px; } -.voucher-lookup { - display: flex; - gap: 8px; - align-items: center; - margin: 0 0 14px; -} +/* Unattributed Section */ +.unattributed-section { + background: #fffdf5; + border: 1px solid $warning-border; + border-radius: 10px; + padding: 0.85rem; + margin-bottom: 1rem; -.voucher-input { - flex: 1; - min-width: 0; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-family: 'Roboto Mono', monospace; - font-size: 13px; - letter-spacing: 0.4px; - color: $primary; - - &:focus { - outline: none; - border-color: $primary; + .attach-btn { + height: 30px; + line-height: 30px; + font-size: 0.78rem; + padding: 0 0.75rem; } - &:disabled { - background: #f4f7fb; - color: $muted; + // A row already linked to the member this dialog is settling -- the same voucher also sits, + // pre-loaded and tickable, in the Member Unapplied Receipts table below. Called out rather than + // removed, since it is the same receipt reachable two ways for two different purposes. + .own-client-row { + background: $success-bg; + + &:hover { + background: $success-bg; + } + + .voucher-badge { + border-color: $success-border; + } + } + + .own-client-badge { + display: block; + margin-top: 2px; + font-size: 0.65rem; + font-weight: 700; + color: $success; } } -.gl-select-field { - margin: 0 0 10px; -} - -.gl-select { - width: 100%; - padding: 9px 11px; - border: 1px solid $border; - border-radius: 6px; - font-size: 13px; - color: $primary; +/* Vouchers Array Card & Table */ +.vouchers-card { + border: 1px solid $border-color; + border-radius: 10px; background: #fff; - - &:focus { - outline: none; - border-color: $primary; - } - - &:disabled { - background: #f4f7fb; - color: $muted; - } + overflow: hidden; + box-shadow: 0 1px 3px rgb(0 0 0 / 5%); } -.loading { +.vouchers-header { display: flex; + justify-content: space-between; align-items: center; - gap: 10px; - padding: 16px 0; - font-size: 13px; - color: $muted; + padding: 0.65rem 1rem; + background: #f1f5f9; + border-bottom: 1px solid $border-color; + + .vouchers-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + font-weight: 700; + color: $text-main; + + .v-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: $accent-blue; + } + + .badge-count { + background: $accent-blue; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + padding: 1px 8px; + border-radius: 999px; + } + } + + .select-all-btn { + font-size: 0.8rem; + color: $accent-blue; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } } -.empty { - display: flex; - gap: 10px; - align-items: flex-start; - background: #f4f7fb; - border: 1px solid $border; - border-radius: 6px; - padding: 12px; - font-size: 13px; -} - -.empty-hint { - color: $muted; - margin-top: 4px; +.table-responsive { + width: 100%; + overflow-x: auto; } .receipt-table { width: 100%; - border-collapse: collapse; - font-size: 13px; - border: 1px solid $border; - border-radius: 6px; - overflow: hidden; + border-collapse: separate; + border-spacing: 0; + font-size: 0.84rem; th { - background: #eef2f8; - color: $primary; - font-size: 11px; - font-weight: 600; + background: $bg-slate; + color: $text-muted; + font-size: 0.72rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.4px; - padding: 9px 12px; - white-space: nowrap; + letter-spacing: 0.04em; + padding: 0.65rem 0.85rem; + border-bottom: 1px solid $border-color; } td { - padding: 10px 12px; - border-bottom: 1px solid #eceff4; - color: #1f2937; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid $border-light; + vertical-align: middle; } - tbody tr:last-child td { - border-bottom: none; - } - - /* Just wide enough for the box, so the voucher number starts near the left edge instead of - after a column of empty space. */ - .col-tick { - width: 36px; - } - - tbody tr { + .receipt-row { cursor: pointer; + transition: background 0.12s ease; &:hover { - background: #f4f7fb; + background: #f8fafc; } &.selected { - background: #e7effb; + background: #eff6ff; + + .voucher-badge { + background: #dbeafe; + color: #1e40af; + border-color: #bfdbfe; + } } } - /* Tabular figures so the amounts line up digit for digit down the column and against the total - below them — the whole point of the column is that it can be read as a sum. */ - .amount-cell { - font-variant-numeric: tabular-nums; - font-weight: 500; - white-space: nowrap; + .col-checkbox { + width: 40px; } - tfoot .total-row { - background: #f4f7fb; - border-top: 2px solid #cbd5e1; + .col-actions { + width: 48px; + } + + .custom-checkbox { + width: 17px; + height: 17px; + accent-color: $accent-blue; + cursor: pointer; + } + + .voucher-badge { + display: inline-block; + background: #f1f5f9; + color: #334155; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid #cbd5e1; + font-weight: 600; + font-size: 0.82rem; + } + + .desc-cell { + color: $text-muted; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: $bg-slate; + border: 1px solid $border-color; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + color: $text-muted; + + .chip-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + } + + .amount-cell { + color: $text-main; + font-size: 0.9rem; + } + + .view-detail-btn { + width: 32px; + height: 32px; + line-height: 32px; + + .eye-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: $accent-blue; + } + } +} + +/* Footer & Status Badges */ +.vouchers-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 1rem; + background: $bg-slate; + border-top: 1.5px solid #cbd5e1; + flex-wrap: wrap; + gap: 0.75rem; + + .selected-summary { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; + + .summary-text { + font-size: 0.82rem; + color: $text-muted; + } + + .summary-amounts { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + + .total-label { + color: $text-muted; + } + + .total-value { + color: $primary; + font-family: 'Courier New', monospace; + } + + .target-divider { + color: $text-muted; + } + + .target-value { + color: $text-muted; + } + } + } +} + +.coverage-status { + .status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.badge-neutral { + background: #f1f5f9; + color: #64748b; + border: 1px solid #cbd5e1; + } + + &.badge-warning { + background: $warning-bg; + color: $warning; + border: 1px solid $warning-border; + } + + &.badge-success { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } +} + +.funding-loading, +.funding-empty { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + font-size: 0.85rem; + background: $bg-slate; + border: 1px solid $border-color; + border-radius: 10px; + color: $text-muted; +} + +/* Alert Boxes */ +.alert-box { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 1rem; + border-radius: 10px; + font-size: 0.84rem; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + } + + &.alert-error { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } +} + +/* Dialog Actions Bar */ +.dialog-actions-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1.5rem !important; + background: $bg-slate; + border-top: 1px solid $border-color; + + .action-btn { + height: 40px; + padding: 0 1.5rem; + border-radius: 8px; + font-size: 0.88rem; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } +} + +/* POPUP MODAL DIALOG STYLES */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 55%); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.voucher-modal-dialog { + background: #fff; + border-radius: 14px; + width: 100%; + max-width: 36rem; + box-shadow: 0 25px 50px -12px rgb(15 23 42 / 30%); + border: 1px solid $border-color; + overflow: hidden; + animation: modal-scale-in 0.2s ease-out; + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); + color: #fff; + + .header-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 700; + + .m-icon { + font-size: 22px; + width: 22px; + height: 22px; + } + + .modal-badge { + background: rgb(255 255 255 / 20%); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.82rem; + } + } + + .close-btn { + color: #fff; + width: 32px; + height: 32px; + line-height: 32px; + } + } + + .modal-body { + padding: 1.25rem; + max-height: 70vh; + overflow-y: auto; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.85rem; + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + .detail-item { + display: flex; + flex-direction: column; + gap: 2px; + background: $bg-slate; + padding: 0.6rem 0.85rem; + border-radius: 8px; + border: 1px solid $border-light; + + .detail-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 600; + } + + .detail-val { + font-size: 0.88rem; + color: $text-main; + } + + &.full-width { + grid-column: 1 / -1; + } + + &.highlight-amount-box { + background: $primary-light; + border-color: $primary-border; + + .detail-label { + color: #1e40af; + } + + .amount-val { + font-size: 1.35rem; + font-weight: 700; + color: #1e3a8a; + } + } + } + + /* The receipt's own real Dr/Cr GL legs -- what this deposit actually posted, never the GL account + currently picked in the by-voucher search box, which is unrelated screen state. */ + .gl-entries-loading, + .gl-entries-empty { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: $text-muted; + margin-top: 4px; + } + + .gl-entries-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + font-size: 0.82rem; + + th { + text-align: left; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-muted; + font-weight: 700; + padding: 4px 8px; + border-bottom: 1px solid $border-color; + } td { - padding-top: 10px; - font-size: 13px; - font-weight: 600; - color: #1f3d7a; + padding: 6px 8px; + color: $text-main; + border-bottom: 1px solid $border-light; + } + + tbody tr:last-child td { border-bottom: none; } } + + .drcr-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + + &.dr { + background: $danger-bg; + color: $danger; + border: 1px solid $danger-border; + } + + &.cr { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .status-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + width: fit-content; + + &.status-unapplied { + background: $success-bg; + color: $success; + border: 1px solid $success-border; + } + } + + .note-box { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + padding: 0.65rem; + font-size: 0.84rem; + color: $text-main; + line-height: 1.45; + margin-top: 4px; + white-space: pre-wrap; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.85rem 1.25rem; + background: $bg-slate; + border-top: 1px solid $border-color; + + .modal-action-btn { + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } } .ta-left { @@ -246,25 +990,50 @@ $success: #1e7d3c; text-align: center; } -.mono { +.font-mono { font-family: 'Courier New', monospace; } -.note { - margin-top: 10px; - font-size: 12px; - color: $muted; +.font-semibold { + font-weight: 600; } -.alert-error { - display: flex; - align-items: center; - gap: 8px; - background: #fdecea; - color: $danger; - border: 1px solid #f5c6c2; - border-radius: 6px; - padding: 10px 12px; - font-size: 13px; - margin-bottom: 12px; +.font-accent { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; +} + +.font-warning { + color: $warning !important; +} + +.mt-6 { + margin-top: 6px; +} + +.mt-8 { + margin-top: 8px; +} + +.text-amber { + color: #b45309; +} + +.animate-fade-in { + animation: fade-in 0.2s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index b556591c7..370bc5708 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { catchError, map } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from 'app/cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; @@ -97,6 +97,18 @@ export class ApplyCollectionDialogComponent implements OnInit { requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null; loadingRequirement = true; + /** + * Share-collection receipts that are approved but belong to nobody yet. + * + *

+ * A receipt paid straight into a GL account names no member — a GL account is nobody's account — + * so it is never attributed on its own. Listing it here is not attribution: it only makes the + * voucher findable, so a counter that tagged a receipt "Share Collection" ten minutes ago is not + * told there is nothing to apply. Attaching it is still a click a person makes against a member + * they can see. + */ + unattributed: any[] = []; + public dialogRef = inject(MatDialogRef); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); @@ -124,6 +136,11 @@ export class ApplyCollectionDialogComponent implements OnInit { this.loading = false; }); + this.cashierService + .getUnattributedShareCollections() + .pipe(catchError(() => of(null))) + .subscribe((res: any) => (this.unattributed = res?.rows || [])); + this.cashierService .getShareAdvanceGlAccounts() .pipe( @@ -135,12 +152,17 @@ export class ApplyCollectionDialogComponent implements OnInit { ) .subscribe((res: any) => { this.glAccounts = res?.glAccounts || []; - // Auto-picked only when there is exactly one live candidate -- the backend already narrowed - // this list to accounts that actually hold an eligible receipt today, so a single option is - // unambiguous. Two or more still means the counter chooses by hand. + // Auto-picked only when it is genuinely the only option. The list now carries every + // postable GL account again (see the backend's own comment), so picking index 0 whenever + // one loads would silently select whichever account sorts first by gl_code -- Cash at + // Vault, almost always -- the exact bug this was fixed for once already. Two or more + // candidates always means the counter chooses by hand. if (this.glAccounts.length === 1 && !this.glAccountNo) { this.glAccountNo = this.glAccounts[0].gl_code; } + if (this.glAccounts.length === 1 && !this.chargeGlAccountNo) { + this.chargeGlAccountNo = this.glAccounts[0].gl_code; + } this.loadingGlAccounts = false; }); @@ -159,6 +181,92 @@ export class ApplyCollectionDialogComponent implements OnInit { }); } + /** Receipt object currently being viewed in detail popup. */ + viewingReceiptDetail: any = null; + + /** + * The receipt's own real Dr/Cr GL legs, under its own {@code gl_transaction_id} — never the GL + * account currently picked in the by-voucher search box above, which is unrelated screen state + * that can name a completely different account than the one this receipt actually sits in. + */ + viewingReceiptGlEntries: any[] | null = null; + loadingReceiptGlEntries = false; + + openReceiptDetail(receipt: any, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.viewingReceiptDetail = receipt; + this.viewingReceiptGlEntries = null; + this.loadingReceiptGlEntries = true; + this.cashierService + .getShareAdvanceGlEntries(receipt.id) + .pipe(catchError(() => of(null))) + .subscribe((res: any) => { + this.viewingReceiptGlEntries = res?.entries || []; + this.loadingReceiptGlEntries = false; + }); + } + + closeReceiptDetail(): void { + this.viewingReceiptDetail = null; + this.viewingReceiptGlEntries = null; + } + + toggleAll(): void { + if (this.isAllSelected()) { + this.selectedIds = []; + } else { + this.selectedIds = this.receipts.map((r) => r.id); + } + this.syncLookupFieldsToSelection(); + } + + /** + * Mirrors a ticked receipt's own GL account and voucher number into the Share Receipt Lookup + * fields above, so a tick is visibly confirmed rather than leaving those fields blank while the + * table below shows the real selection. Particularly worth seeing across instalments, where two + * receipts making up one settlement can sit in two different GL accounts, exactly like this + * member's own 200902 and 400105 receipts do. + * + * Shows the most recently ticked receipt when several are selected — there is no single GL + * account to show once more than one is chosen, and the most recent tick is the one the counter + * just made. Clears both fields once nothing is ticked. + */ + private syncLookupFieldsToSelection(): void { + if (!this.selectedIds.length) { + this.glAccountNo = ''; + this.voucherNo = ''; + return; + } + const lastId = this.selectedIds[this.selectedIds.length - 1]; + const receipt = this.receipts.find((r) => r.id === lastId); + if (receipt) { + this.glAccountNo = receipt.gl_code || this.glAccountNo; + this.voucherNo = receipt.voucher_no || receipt.receipt_no || ''; + } + } + + /** + * Whether an unattributed-list row is already linked to the member this dialog is settling. + * + * Such a row also appears, pre-loaded, in the Member Unapplied Receipts table below — that is not + * a duplicate to clear up, it is the same receipt reachable two ways. This only flags it so the + * counter understands why it shows up here too, rather than reading it as an unrelated stray + * voucher that happens to look identical. + */ + isOwnClientVoucher(u: any): boolean { + return u?.linkedClientId != null && Number(u.linkedClientId) === Number(this.data.clientId); + } + + isAllSelected(): boolean { + return this.receipts.length > 0 && this.selectedIds.length === this.receipts.length; + } + + isSomeSelected(): boolean { + return this.selectedIds.length > 0 && this.selectedIds.length < this.receipts.length; + } + /** * What the share receipts still need to reach — the share value alone once the entry fee has its * own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the @@ -221,6 +329,7 @@ export class ApplyCollectionDialogComponent implements OnInit { ...this.selectedIds, id ]; + this.syncLookupFieldsToSelection(); } /** @@ -243,12 +352,48 @@ export class ApplyCollectionDialogComponent implements OnInit { * voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing * later inside apply(). */ + /** + * Attaches one of the unattributed share-collection receipts to this member. + * + *

+ * Goes straight to {@link resolveVoucher} rather than through {@link lookupVoucher}: a row already + * on this list is either a fresh cashier deposit (which does carry its own GL account) or an + * already-adopted {@code m_share_advance_collection} row (the "linked to …" ones), whose GL account + * can legitimately come back blank — its subquery reads the posting the original adoption made, + * which a clerk-disbursement-funded receipt does not always leave behind cleanly. Either way the + * backend needs no GL account to resolve a voucher it can already find by number, so a blank one + * here must never block the click the way it rightly blocks a hand-typed search with nothing chosen + * yet. The list never decides whose money it is; the click does. + */ + attachUnattributed(row: any): void { + if (this.lookingUp || this.submitting) { + return; + } + this.resolveVoucher(row.voucherNo, row.glAccountNo || ''); + } + lookupVoucher(): void { const voucher = this.voucherNo.trim(); const glAccountNo = this.glAccountNo.trim(); + // A hand-typed search genuinely needs both fields: an empty GL account here means nothing has + // been chosen yet, not that one isn't needed — unlike {@link attachUnattributed}, which already + // knows exactly which receipt it means and never has this ambiguity. if (!voucher || !glAccountNo || this.lookingUp) { return; } + this.resolveVoucher(voucher, glAccountNo); + } + + /** + * Resolves a voucher number (with whatever GL account, if any, is known for it) and adds the + * receipt it finds to the table, ticked. + * + * Shared by {@link lookupVoucher} (a hand-typed search, where both fields are guaranteed non-blank + * by the Find button's own disabled state) and {@link attachUnattributed} (a click on a row already + * on screen, whose GL account can legitimately be blank) — the actual lookup and its bookkeeping + * afterwards are identical either way. + */ + private resolveVoucher(voucher: string, glAccountNo: string): void { this.lookingUp = true; this.errorMsg = ''; this.findVoucher(voucher, glAccountNo).subscribe((res) => { @@ -257,8 +402,8 @@ export class ApplyCollectionDialogComponent implements OnInit { return; } // Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which - // are left for the clerk: typing a voucher number IS the deliberate choice of that receipt, - // so making them tick it again would be asking the same question twice. + // are left for the clerk: attaching a specific voucher IS the deliberate choice of that + // receipt, so making them tick it again would be asking the same question twice. this.receipts = [ res, ...this.receipts.filter((r) => r.id !== res.id) @@ -269,7 +414,19 @@ export class ApplyCollectionDialogComponent implements OnInit { res.id ]; } + // Once attached it belongs to this member, so it leaves the unattributed list rather than + // sitting there inviting a second, conflicting attachment. + this.unattributed = this.unattributed.filter((u) => u.voucherNo !== voucher); + // Cleared, not left showing this voucher: an instalment payment often means searching for a + // second receipt right after this one, and the box should be ready for that number, not still + // holding the one just added. this.voucherNo = ''; + // The GL Account picker still follows this receipt, exactly as it does when an already-listed + // receipt is ticked — res.gl_code is this receipt's own real GL account (see findByVoucher()'s + // subquery on the server), not whatever the search box happened to hold before this search. + if (res.gl_code) { + this.glAccountNo = res.gl_code; + } }); } @@ -311,14 +468,9 @@ export class ApplyCollectionDialogComponent implements OnInit { * own for the entry fee — since the two are never allowed to mix into the same sum. */ private findVoucher(voucher: string, glAccountNo: string) { + // No client-match check here by deliberate choice: any receipt can be attached to any share + // account, regardless of which member it was originally recorded against. return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe( - map((res: any) => { - if (Number(res.client_id) !== Number(this.data.clientId)) { - this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`; - return null; - } - return res; - }), catchError((err) => { this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); return of(null); diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index 84103260f..d97a3125c 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -149,6 +149,10 @@ export class SharesAccountViewComponent implements OnInit { // capital that was never issued. if (!this.isSettled) { this.buttonConfig.removeOption('Share Certificate'); + // A further tranche on top of an application the member never paid for makes no sense: the + // first allotment is still sitting unissued, and a second one would only grow a holding that + // does not exist yet. Settle the original collection first, then add to it. + this.buttonConfig.removeButton('Apply Additional Shares'); } else { // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time // step. Once collectionApplied is true there is nothing left to settle this way; any @@ -202,7 +206,9 @@ export class SharesAccountViewComponent implements OnInit { */ private applyCollection() { const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, { - width: '560px', + width: '820px', + maxWidth: '92vw', + panelClass: 'apply-collection-dialog-panel', data: { shareAccountId: this.sharesAccountData.id, clientId: this.sharesAccountData.clientId, diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index a2f7f614f..d2c17ecd1 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -41,6 +41,139 @@ const routes: Routes = [ data: { title: 'Share Holders', breadcrumb: 'Share Holders' }, component: ShareHoldersComponent }, + // Share reports. Lazily loaded one component at a time: a clerk opening the Share menu for a + // withdrawal should not pay for four report screens they did not ask for, and each is + // self-contained enough that there is nothing to share by bundling them together. + { + path: 'reports/final-balance', + data: { title: 'Share Final Balance', breadcrumb: 'Final Balance' }, + loadComponent: () => + import('./reports/share-final-balance/share-final-balance.component').then( + (m) => m.ShareFinalBalanceComponent + ) + }, + { + path: 'reports/account-statement', + data: { title: 'Share Account Statement', breadcrumb: 'Account Statement' }, + loadComponent: () => + import('./reports/share-account-statement/share-account-statement.component').then( + (m) => m.ShareAccountStatementComponent + ) + }, + { + path: 'reports/share-issued', + data: { title: 'Share Issued Register', breadcrumb: 'Share Issued' }, + loadComponent: () => + import('./reports/share-issued-report/share-issued-report.component').then( + (m) => m.ShareIssuedReportComponent + ) + }, + { + path: 'reports/share-status', + data: { title: 'Share Status Report', breadcrumb: 'Share Status' }, + loadComponent: () => + import('./reports/share-status-report/share-status-report.component').then( + (m) => m.ShareStatusReportComponent + ) + }, + { + path: 'reports/nominal-shares', + data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' }, + loadComponent: () => + import('./reports/share-nominal-report/share-nominal-report.component').then( + (m) => m.ShareNominalReportComponent + ) + }, + { + path: 'reports/share-wise', + data: { title: 'Share Wise Report', breadcrumb: 'Share Wise Report' }, + loadComponent: () => + import('./reports/share-wise-report/share-wise-report.component').then((m) => m.ShareWiseReportComponent) + }, + { + path: 'reports/guarantor-detail', + data: { title: 'Guarantor Detail', breadcrumb: 'Guarantor Detail' }, + loadComponent: () => + import('./reports/share-guarantor-report/share-guarantor-report.component').then( + (m) => m.ShareGuarantorReportComponent + ) + }, + { + path: 'reports/member-interest', + data: { title: 'Total Interest Received & Receivable', breadcrumb: 'Total Int Rec' }, + loadComponent: () => + import('./reports/share-member-interest-report/share-member-interest-report.component').then( + (m) => m.ShareMemberInterestReportComponent + ) + }, + { + path: 'reports/enquiry', + data: { title: 'Share Enquiry', breadcrumb: 'Share Enquiry' }, + loadComponent: () => + import('./reports/share-enquiry/share-enquiry.component').then((m) => m.ShareEnquiryComponent) + }, + { + path: 'reports/payment-certificate', + data: { title: 'Share Payment Certificate', breadcrumb: 'Payment Certificate' }, + loadComponent: () => + import('./reports/share-payment-certificate/share-payment-certificate.component').then( + (m) => m.SharePaymentCertificateComponent + ) + }, + { + path: 'reports/share-detail', + data: { title: 'Share Detail', breadcrumb: 'Share Detail' }, + loadComponent: () => + import('./reports/share-detail-report/share-detail-report.component').then( + (m) => m.ShareDetailReportComponent + ) + }, + { + path: 'reports/dividend-statement', + data: { title: 'Dividend Statement', breadcrumb: 'Dividend Statement' }, + loadComponent: () => + import('./reports/share-dividend-statement/share-dividend-statement.component').then( + (m) => m.ShareDividendStatementComponent + ) + }, + { + path: 'reports/folio-statement', + data: { title: 'Folio Statement', breadcrumb: 'Folio Statement' }, + loadComponent: () => + import('./reports/share-folio-statement/share-folio-statement.component').then( + (m) => m.ShareFolioStatementComponent + ) + }, + { + path: 'transfers', + data: { title: 'Share Transfer', breadcrumb: 'Share Transfer' }, + loadComponent: () => + import('./reports/share-transfer/share-transfer.component').then((m) => m.ShareTransferComponent) + }, + { + path: 'reports/distinctive-numbers', + data: { title: 'Share Distinctive Number Detail', breadcrumb: 'Distinctive Numbers' }, + loadComponent: () => + import('./reports/share-distinctive-numbers/share-distinctive-numbers.component').then( + (m) => m.ShareDistinctiveNumbersComponent + ) + }, + { + path: 'reports/area-wise', + data: { title: 'Area Wise Shareholding', breadcrumb: 'Area Wise' }, + loadComponent: () => + import('./reports/share-area-wise/share-area-wise.component').then((m) => m.ShareAreaWiseComponent) + }, + { + path: 'reports/labels', + data: { title: 'Share Labels', breadcrumb: 'Share Label' }, + loadComponent: () => import('./reports/share-label/share-label.component').then((m) => m.ShareLabelComponent) + }, + { + path: 'reports/letters', + data: { title: 'Share Letter', breadcrumb: 'Share Letter' }, + loadComponent: () => import('./reports/share-letter/share-letter.component').then((m) => m.ShareLetterComponent) + }, { path: 'create', data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' }, diff --git a/src/app/shares/shares.service.ts b/src/app/shares/shares.service.ts index 0bf0f7d71..a7d277584 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -190,4 +190,176 @@ export class SharesService { rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable { return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' }); } + + // ── Share reports ────────────────────────────────────────────────────── + // + // Each of these is a single server-side query returning finished rows plus its own totals, rather + // than the fetch-all-accounts-then-fan-out pattern the older letter-mgmt reports use. That pattern + // stops at the 200-account page it asks for and recomputes totals from whatever arrived, which a + // report a balance sheet has to reconcile to cannot do. + + /** + * Share capital held per member as on a date, derived from settled purchases less redemptions. + * + * @param params asOnDate (yyyy-MM-dd), officeId, productId, includeZero — all optional. + */ + getShareFinalBalance(params: { + asOnDate?: string; + officeId?: number | string; + productId?: number | string; + includeZero?: boolean; + }): Observable { + return this.http.get('/sharereports/final-balance', { params: this.cleanParams(params) }); + } + + /** One share account's movements in date order, with the running holding after each. */ + getShareAccountStatement( + shareAccountId: number | string, + params: { fromDate?: string; toDate?: string } = {} + ): Observable { + return this.http.get(`/sharereports/account-statement/${shareAccountId}`, { params: this.cleanParams(params) }); + } + + /** Allotment register — one row per tranche of shares issued in the period. */ + getShareIssuedReport(params: { + fromDate?: string; + toDate?: string; + officeId?: number | string; + productId?: number | string; + status?: number | string; + }): Observable { + return this.http.get('/sharereports/share-issued', { params: this.cleanParams(params) }); + } + + /** Share accounts grouped by status and product, with the accounts behind each group. */ + getShareStatusSummary(params: { officeId?: number | string; productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/status-summary', { params: this.cleanParams(params) }); + } + + /** Holdings measured against the share class's own nominal, minimum and maximum, at face value. */ + getShareNominalReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) }); + } + + /** Shareholding pattern — members and capital per holding band, plus the per-product split. */ + getShareWiseReport( + params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-wise', { params: this.cleanParams(params) }); + } + + /** Active loan guarantors with the shareholding each one holds in the society. */ + getShareGuarantorDetail(params: { officeId?: number | string; onlyMembers?: boolean } = {}): Observable { + return this.http.get('/sharereports/guarantor-detail', { params: this.cleanParams(params) }); + } + + /** Interest received and receivable from the members who hold share capital. */ + getShareMemberLoanInterest( + params: { officeId?: number | string; includeClosedLoans?: boolean } = {} + ): Observable { + return this.http.get('/sharereports/member-loan-interest', { params: this.cleanParams(params) }); + } + + /** + * One member's whole share position — accounts, movements, dividends and linked savings. + * + * Sends `search` when the member is being looked for and `clientId` once one is chosen: a search + * matching several members comes back as a list to pick from rather than guessing between them. + */ + getShareEnquiry(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/enquiry', { params: this.cleanParams(params) }); + } + + /** What a member actually paid for their shares, and against which receipt — for the certificate. */ + getSharePaymentCertificate(shareAccountId: number | string): Observable { + return this.http.get(`/sharereports/payment-certificate/${shareAccountId}`); + } + + /** The full share register: every account with its class, terms, lock-in, charges and resolution. */ + getShareDetailRegister( + params: { officeId?: number | string; productId?: number | string; status?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/share-detail', { params: this.cleanParams(params) }); + } + + /** One member's dividends across every year, declared against paid. */ + getShareDividendStatement(params: { search?: string; clientId?: number | string }): Observable { + return this.http.get('/sharereports/dividend-statement', { params: this.cleanParams(params) }); + } + + /** One member's folio: every share account they hold, with the movements running across all of them. */ + getShareFolioStatement(params: { + search?: string; + clientId?: number | string; + fromDate?: string; + toDate?: string; + }): Observable { + return this.http.get('/sharereports/folio-statement', { params: this.cleanParams(params) }); + } + + /** The numbered share range each allotment covers, derived from issue order per share class. */ + getShareDistinctiveNumbers( + params: { productId?: number | string; clientId?: number | string } = {} + ): Observable { + return this.http.get('/sharereports/distinctive-numbers', { params: this.cleanParams(params) }); + } + + /** Members and share capital grouped by the area their address puts them in. */ + getShareAreaWise(params: { productId?: number | string } = {}): Observable { + return this.http.get('/sharereports/area-wise', { params: this.cleanParams(params) }); + } + + /** + * Name, address, folio and holding for every shareholding member. + * + * One call behind both Share Label and Share Letter — they want the same rows and differ only in + * what is printed around them. + */ + getShareMailingList( + params: { productId?: number | string; officeId?: number | string; area?: string } = {} + ): Observable { + return this.http.get('/sharereports/mailing-list', { params: this.cleanParams(params) }); + } + + // ── Share transfers (maker-checker) ──────────────────────────────────── + + /** Records a transfer between two members. Nothing moves until a checker approves it. */ + submitShareTransfer(data: any): Observable { + return this.http.post('/sharetransfers', data); + } + + /** The transfer register — every transfer with both members and the resolution behind it. */ + getShareTransfers(status?: string): Observable { + return this.http.get('/sharetransfers', { params: this.cleanParams({ status }) }); + } + + /** Approves a transfer: re-checks every rule, then moves the shares. */ + approveShareTransfer(transferId: number): Observable { + return this.http.post(`/sharetransfers/${transferId}/approve`, {}); + } + + rejectShareTransfer(transferId: number, rejectionReason?: string): Observable { + return this.http.post(`/sharetransfers/${transferId}/reject`, { rejectionReason: rejectionReason || '' }); + } + + /** + * Drops empty filters rather than sending them blank — the report endpoints treat an absent + * parameter as "no filter", but a present-and-empty one would be read as a real value. + */ + private cleanParams(params: Record): HttpParams { + let httpParams = new HttpParams(); + Object.entries(params).forEach( + ([ + key, + value + ]) => { + if (value !== null && value !== undefined && value !== '') { + httpParams = httpParams.set(key, String(value)); + } + } + ); + return httpParams; + } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..858410ec4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': 'e53dfb5a' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index c6f7900ea..7b96bfb34 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1960,3 +1960,14 @@ mifosx-client-datatable-step * { display: none !important; } } + +/* Apply Collection Dialog Panel Styling */ +.apply-collection-dialog-panel { + .mat-mdc-dialog-container, + .mdc-dialog__surface { + border-radius: 16px !important; + padding: 0 !important; + overflow: hidden !important; + box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important; + } +}