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 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:06:02 +05:30
co-authored by Claude Sonnet 5
parent e53dfb5a66
commit c7428d0cb2
51 changed files with 10721 additions and 429 deletions
+185
View File
@@ -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.
*
* <p>
* 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();
}