feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup

Apply Collection and Apply Additional Shares settle the same Share Collection
receipts for the same member, so they are now deliberately the same screen.

Apply Collection takes exactly two receipts and no more: one for the share value,
one for the entry fee, in two separate slots. They are different things -- the
share receipt is money moving into share capital, while the fee receipt only
proves the fee was already collected and is never posted a second time -- and
picking both out of one ticked list made that distinction invisible.

Apply Additional Shares moves from its own route to a dialog. It was a full page
a clerk had to leave the account for and come back from, to fill in a short form
with the member standing at the counter. Its old route, resolver case and action-
host branch are removed rather than left behind as dead paths.

Both screens now draw their receipts from a single pool, whether a receipt
already names the member or is a voucher paid straight into a GL account; an
unattributed one is attached at the moment it is picked, so what used to be an
Attach in one table and a tick in another is one deliberate click. The by-voucher
search and the GL-entry detail both sit behind buttons, as exceptions rather than
permanent furniture.

Dialog chrome and the receipt shape are shared (share-dialog.shared.scss,
share-receipt-option.ts) so the two cannot drift apart again.

Also fixes content being clipped at the dialog edges: the wrapper cancelled a
24px surface padding that the panel class had already zeroed, pushing every edge
outside the surface where overflow:hidden cut it off, and the height cap sat on
the scrolling body rather than the wrapper, letting the footer stack past the
bottom of the viewport.

Adds the product-wise share summary report (authorised, issued, available and
unpaid shares, distinctive range and account count per product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:56:34 +05:30
co-authored by Claude Opus 5
parent dc7f668056
commit 3c363d0372
22 changed files with 2906 additions and 4229 deletions
+9 -12
View File
@@ -391,20 +391,17 @@ export class CashierService {
}
/**
* Approved share-collection receipts that name no member yet.
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
* deposits and receipts linked to another member — which attach by voucher instead.
*
* 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.
* One request rather than two: both lists come from the same table under the same filters, and
* splitting them across endpoints only meant the same rows were fetched twice and, where they
* overlapped, drawn twice on the same screen.
*/
getUnattributedShareCollections(): Observable<any> {
return this.http.get('/shareadvancecollection/unattributed');
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
getShareCollectionReceipts(clientId?: number): Observable<any> {
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
return this.http.get('/shareadvancecollection/unattributed', { params });
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
+3
View File
@@ -1353,6 +1353,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/product-summary']">
<fa-icon icon="layer-group" class="menu-item-icon"></fa-icon> Product Summary
</button>
</div>
<!-- Share Admin -->
<div class="deposit-menu-group">
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
const actionName = route.paramMap.get('name');
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
switch (actionName) {
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
@@ -0,0 +1,168 @@
<!--
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/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="layer-group" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Product Summary</h2>
<div class="cert-head-s">
Every share class against its own authorised capital — shares issued, shares remaining, and the
distinctive-number range issued so far. "Issued" means paid up, not merely allotted.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !rows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !rows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (rows.length) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="layer-group" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.authorisedShares | number }}</div>
<div class="cert-kpi-l">Authorised Shares (all classes)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.issuedShares | number }}</div>
<div class="cert-kpi-l">Shares Issued (paid up)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.issuedCapital | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital Issued</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.unpaidShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.unpaidShares | number }}</div>
<div class="cert-kpi-l">Allotted, Unpaid</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="srep-state">Loading product summary…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No share product is configured.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Class</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Authorised</th>
<th class="srep-num">Issued (Paid Up)</th>
<th class="srep-num">Available</th>
<th>Utilisation</th>
<th class="srep-num">Allotted, Unpaid</th>
<th>Distinctive Range</th>
<th class="srep-num">Accounts</th>
<th>Min / Nominal / Max</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.productId) {
<tr>
<td>
{{ row.productName }}
@if (row.productShortName) {
<div class="srep-mono srep-sub" style="color: inherit; opacity: 0.65">{{ row.productShortName }}</div>
}
</td>
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">
{{ row.authorisedShares | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.authorisedCapital | number: '1.0-0' }}
</div>
</td>
<td class="srep-num">
{{ row.issuedShares | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.issuedCapital | number: '1.0-0' }}
</div>
</td>
<td class="srep-num">{{ row.availableShares | number }}</td>
<td>
<div class="product-utilisation-bar">
<div class="product-utilisation-fill" [style.width.%]="utilisationPercent(row)"></div>
</div>
<div class="srep-mono" style="font-size: 0.72rem; color: #64748b">
{{ utilisationPercent(row) | number: '1.0-1' }}%
</div>
</td>
<td class="srep-num">
@if (row.unpaidShares) {
<span class="srep-pill srep-pill--amber">{{ row.unpaidShares | number }}</span>
<div class="srep-sub" style="color: inherit; opacity: 0.65">
₹{{ row.unpaidAmount | number: '1.0-0' }} · {{ row.unpaidAccounts }}
{{ row.unpaidAccounts === 1 ? 'account' : 'accounts' }}
</div>
} @else {
<span class="srep-pill srep-pill--green">0</span>
}
</td>
<td class="srep-mono">{{ distinctiveRange(row) }}</td>
<td class="srep-num">
{{ row.accountCount | number }}
<div class="srep-sub" style="color: inherit; opacity: 0.65">
{{ row.memberCount | number }} {{ row.memberCount === 1 ? 'member' : 'members' }}
</div>
</td>
<td class="srep-mono">
{{ row.minimumClientShares ?? '—' }} / {{ row.nominalClientShares ?? '—' }} /
{{ row.maximumClientShares ?? '—' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total — {{ rows.length | number }} share classes</td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals.authorisedShares | number }}</td>
<td class="srep-num">{{ totals.issuedShares | number }}</td>
<td class="srep-num" colspan="2"></td>
<td class="srep-num">{{ totals.unpaidShares | number }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
@if (totals.unpaidShares) {
<div class="srep-warn">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>
<b>{{ totals.unpaidShares | number }}</b> shares across every class are allotted but not yet paid for —
<b>₹{{ totals.unpaidAmount | number: '1.2-2' }}</b> in all. They are excluded from "Issued" above; see Final
Balance for which members they belong to.
</span>
</div>
}
}
</div>
@@ -0,0 +1,168 @@
/**
* 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 { 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 ProductSummaryRow {
productId: number;
productName: string;
productShortName: string;
unitPrice: number;
minimumClientShares: number;
nominalClientShares: number;
maximumClientShares: number;
authorisedShares: number;
authorisedCapital: number;
issuedShares: number;
issuedCapital: number;
availableShares: number;
unpaidAccounts: number;
unpaidShares: number;
unpaidAmount: number;
distinctiveFrom: number | null;
distinctiveTo: number | null;
accountCount: number;
memberCount: number;
}
/**
* Share Product Summary — every share class measured against its own authorised capital: how many
* shares are issued, how many remain, and the distinctive-number range issued so far.
*
* <p>
* "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other
* report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That
* platform column counts an allotment the moment it is submitted, before any money has necessarily
* arrived, so it runs ahead of what the society has actually received. A board reading this page is
* reading the same figure the GL Share Capital account would show, not a subscription count.
*/
@Component({
selector: 'mifosx-share-product-summary',
templateUrl: './share-product-summary.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FaIconComponent
]
})
export class ShareProductSummaryComponent implements OnInit {
private sharesService = inject(SharesService);
loading = true;
loadError = '';
rows: ProductSummaryRow[] = [];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareProductSummary()
.pipe(
catchError((err: any) => {
this.loadError = this.readError(err, 'The product summary could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.loading = false;
});
}
/** How much of a product's authorised capital is actually issued, for the progress bar. */
utilisationPercent(row: ProductSummaryRow): number {
if (!row.authorisedShares) {
return 0;
}
const pct = (row.issuedShares / row.authorisedShares) * 100;
return Math.max(0, Math.min(100, pct));
}
distinctiveRange(row: ProductSummaryRow): string {
if (!row.distinctiveFrom || !row.distinctiveTo) {
return 'None allotted yet';
}
return `${row.distinctiveFrom} – ${row.distinctiveTo}`;
}
get totals() {
return {
authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0),
authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0),
issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0),
issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0),
unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0),
unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0)
};
}
exportCsv(): void {
shareReportCsv(
'share-product-summary',
[
'Product',
'Unit Price',
'Authorised Shares',
'Authorised Capital',
'Issued Shares',
'Issued Capital',
'Available Shares',
'Unpaid Accounts',
'Unpaid Shares',
'Unpaid Amount',
'Distinctive Range',
'Accounts',
'Members',
'Min / Nominal / Max Per Member'
],
this.rows.map((r) => [
r.productName,
r.unitPrice,
r.authorisedShares,
r.authorisedCapital,
r.issuedShares,
r.issuedCapital,
r.availableShares,
r.unpaidAccounts,
r.unpaidShares,
r.unpaidAmount,
this.distinctiveRange(r),
r.accountCount,
r.memberCount,
`${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}`
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -278,6 +278,23 @@ $srep-muted: #64748b;
font-weight: 500;
}
/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two
numbers beside it. */
.product-utilisation-bar {
width: 100%;
min-width: 90px;
height: 6px;
border-radius: 999px;
background: $srep-border-soft;
overflow: hidden;
}
.product-utilisation-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2563eb, #059669);
}
/* 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 {
+996
View File
@@ -0,0 +1,996 @@
/**
* 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 chrome for Apply Collection and Apply Additional Shares dialogs.
// Each component @uses this, so every rule is emitted once per component
// and stays scoped to it -- nothing here leaks into the global stylesheet.
/* ── Design tokens ─────────────────────────────────────────────────────── */
$primary: #1e3a8a;
$primary-mid: #2448b0;
$accent: #2563eb;
$accent-soft: #eff6ff;
$accent-border: #bfdbfe;
$text-main: #0f172a;
$text-sub: #334155;
$text-muted: #64748b;
$border-color: #e2e8f0;
$bg-page: #f8fafc;
$bg-white: #fff;
$success: #059669;
$success-bg: #ecfdf5;
$success-border: #6ee7b7;
$danger: #dc2626;
$danger-bg: #fef2f2;
$danger-border: #fca5a5;
/* ── Utilities ─────────────────────────────────────────────────────────── */
.mono {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
}
/* ── Dialog wrapper ────────────────────────────────────────────────────── */
.dialog-wrapper {
display: flex;
flex-direction: column;
max-height: 88vh;
background: $bg-white;
border-radius: 14px;
overflow: hidden;
}
/* ── Header ────────────────────────────────────────────────────────────── */
.dialog-header {
display: flex;
flex: none;
align-items: center;
gap: 0.85rem;
padding: 1.1rem 1.5rem;
background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%);
color: #fff;
.header-icon {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 9px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 20%);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #fff;
}
}
.header-text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 1.08rem;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 0.18rem 0 0;
font-size: 0.78rem;
opacity: 0.78;
}
}
.header-close {
color: rgb(255 255 255 / 80%);
flex: none;
&:hover {
color: #fff;
background: rgb(255 255 255 / 12%) !important;
}
}
}
/* ── Body ──────────────────────────────────────────────────────────────── */
.dialog-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 1.1rem;
min-height: 0;
max-height: none !important;
padding: 1.4rem 1.5rem !important;
overflow-y: auto;
background: $bg-page;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 99px;
}
}
.state-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 0;
color: $text-muted;
font-size: 0.85rem;
}
/* ═══════════════════════════════════════════════════════════════════════
Collection Summary Card (sc-* prefix)
═══════════════════════════════════════════════════════════════════════ */
.sc-card {
border: 1px solid $accent-border;
border-radius: 10px;
background: $bg-white;
// No overflow:hidden — it clips row content in certain flex contexts
box-shadow: 0 2px 8px rgb(37 99 235 / 8%);
}
/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */
.sc-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.15rem;
background: $bg-white;
border-left: 4px solid transparent;
transition:
background 0.15s,
border-color 0.2s;
&.sc-sep {
border-top: 1px solid $border-color;
}
&.sc-ok {
background: $success-bg;
border-left-color: $success;
}
&.sc-bad {
background: $danger-bg;
border-left-color: $danger;
}
}
/* Left side: label + required amount */
.sc-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.sc-label {
display: block;
font-size: 0.86rem;
font-weight: 700;
color: $text-main;
line-height: 1.3;
min-height: 1em; // guarantees height even if content is empty
}
.sc-sub {
display: block;
font-size: 0.72rem;
color: $text-muted;
line-height: 1.35;
min-height: 1em;
b {
font-weight: 600;
color: $text-sub;
}
}
/* Right side: collected amount + badge */
.sc-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex: none;
}
.sc-val {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
font-size: 0.96rem;
font-weight: 700;
color: $text-muted;
letter-spacing: -0.01em;
transition: color 0.2s;
&.sc-val-ok {
color: $success;
}
&.sc-val-bad {
color: $danger;
}
}
.sc-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 99px;
font-size: 0.68rem;
font-weight: 700;
white-space: nowrap;
&.sc-tag-ok {
background: $success-bg;
color: $success;
border: 1px solid $success-border;
}
&.sc-tag-bad {
background: $danger-bg;
color: $danger;
border: 1px solid $danger-border;
}
}
/* ── Total row ─────────────────────────────────────────────────────────── */
.sc-total-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1.15rem;
border-top: 1px solid $border-color;
background: $bg-page;
transition: background 0.2s;
&.sc-total-ok {
background: $success-bg;
}
}
.sc-total-lbl {
font-size: 0.82rem;
font-weight: 800;
color: $text-main;
letter-spacing: 0.01em;
}
.sc-total-nums {
display: flex;
align-items: center;
gap: 0.45rem;
flex: none;
}
.sc-total-req {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 0.78rem;
font-weight: 500;
color: $text-muted;
}
.sc-arrow {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 300;
}
.sc-total-got {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
font-size: 1rem;
font-weight: 800;
color: $text-muted;
letter-spacing: -0.02em;
transition: color 0.2s;
&.sc-total-got-ok {
color: $success;
}
}
.sc-status-pill {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.65rem;
border-radius: 99px;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
white-space: nowrap;
transition: all 0.2s;
&.sc-pill-ok {
background: $success;
color: #fff;
box-shadow: 0 2px 6px rgb(5 150 105 / 30%);
}
&.sc-pill-pending {
background: $border-color;
color: $text-muted;
}
}
/* ═══════════════════════════════════════════════════════════════════════ */
/* ── Receipt step slots ────────────────────────────────────────────────── */
.slot {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid $border-color;
border-radius: 10px;
background: $bg-white;
box-shadow: 0 1px 4px rgb(15 23 42 / 4%);
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 3px 12px rgb(15 23 42 / 7%);
}
}
.slot-head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.slot-step {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(135deg, $primary 0%, $accent 100%);
color: #fff;
font-size: 0.72rem;
font-weight: 800;
box-shadow: 0 2px 6px rgb(37 99 235 / 30%);
}
.slot-title {
flex: 1;
min-width: 0;
h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 700;
color: $text-main;
}
p {
margin: 0.12rem 0 0;
font-size: 0.72rem;
color: $text-muted;
}
}
.slot-search {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: none;
padding: 0.38rem 0.72rem;
border: 1.5px solid $accent-border;
border-radius: 7px;
background: $accent-soft;
color: $accent;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
mat-icon {
width: 15px;
height: 15px;
font-size: 15px;
}
&:hover {
background: $accent;
border-color: $accent;
color: #fff;
box-shadow: 0 2px 8px rgb(37 99 235 / 25%);
}
}
/* ── Pickable option list ───────────────────────────────────────────────── */
.options {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 210px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 99px;
}
}
.option {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.72rem 0.9rem;
border: 1.5px solid $border-color;
border-radius: 7px;
cursor: pointer;
background: $bg-white;
transition: all 0.14s;
&:hover {
border-color: $accent;
background: $accent-soft;
box-shadow: 0 2px 6px rgb(37 99 235 / 10%);
}
&.option-busy {
opacity: 0.6;
pointer-events: none;
}
}
.option-radio {
flex: none;
width: 14px;
height: 14px;
border: 2px solid #cbd5e1;
border-radius: 50%;
transition: border-color 0.14s;
.option:hover & {
border-color: $accent;
}
}
.option-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.option-voucher {
font-size: 0.81rem;
font-weight: 700;
color: $text-main;
}
.option-meta {
font-size: 0.7rem;
color: $text-muted;
}
.option-desc {
font-size: 0.7rem;
color: $text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.option-amount {
flex: none;
font-size: 0.85rem;
font-weight: 700;
color: $text-main;
}
.option-info {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $border-color;
border-radius: 5px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all 0.14s;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
&:hover {
color: $accent;
border-color: $accent;
background: $accent-soft;
}
}
/* ── Chosen receipt ─────────────────────────────────────────────────────── */
.chosen {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.75rem 1rem;
border: 2px solid $success-border;
border-radius: 9px;
background: $success-bg;
box-shadow: 0 2px 8px rgb(5 150 105 / 10%);
&.chosen-short {
border-color: $danger-border;
background: $danger-bg;
box-shadow: 0 2px 8px rgb(220 38 38 / 8%);
}
}
.chosen-badge {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 30px;
height: 30px;
border-radius: 7px;
background: $success;
.chosen-short & {
background: $danger;
}
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #fff;
}
}
.chosen-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.chosen-voucher {
font-size: 0.84rem;
font-weight: 700;
color: $text-main;
}
.chosen-gl {
font-size: 0.7rem;
color: $text-muted;
}
.chosen-amount {
flex: none;
font-size: 0.95rem;
font-weight: 800;
color: $text-main;
font-family: 'Roboto Mono', 'Courier New', monospace;
}
.chosen-clear {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $danger-border;
border-radius: 5px;
background: transparent;
color: #f87171;
cursor: pointer;
transition: all 0.14s;
mat-icon {
width: 15px;
height: 15px;
font-size: 15px;
}
&:hover {
color: $danger;
background: $danger-bg;
border-color: $danger;
}
}
/* ── Slot notes ─────────────────────────────────────────────────────────── */
.slot-note {
margin: 0;
font-size: 0.74rem;
font-weight: 500;
&.note-good {
color: $success;
}
&.note-bad {
color: $danger;
font-weight: 700;
}
&.note-muted {
color: $text-muted;
}
}
.slot-empty {
margin: 0;
padding: 0.8rem 1rem;
border: 1.5px dashed $border-color;
border-radius: 7px;
background: $bg-page;
font-size: 0.75rem;
line-height: 1.55;
color: $text-muted;
}
/* ── Error alert ─────────────────────────────────────────────────────────── */
.alert {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
border: 1.5px solid $danger-border;
border-radius: 7px;
background: $danger-bg;
color: $danger;
font-size: 0.76rem;
line-height: 1.45;
mat-icon {
flex: none;
width: 17px;
height: 17px;
font-size: 17px;
margin-top: 1px;
}
}
/* ── Footer ─────────────────────────────────────────────────────────────── */
.dialog-actions {
display: flex;
flex: none;
justify-content: flex-end;
align-items: center;
gap: 0.75rem;
min-height: 0;
padding: 1rem 1.5rem !important;
margin: 0 !important;
border-top: 1px solid $border-color;
background: $bg-white;
box-shadow: 0 -2px 8px rgb(15 23 42 / 5%);
// Both buttons: same height, same border-radius, same font
button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 0.3rem !important;
height: 40px !important;
min-width: 110px !important;
padding: 0 1.1rem !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 0.85rem !important;
line-height: 1 !important;
letter-spacing: 0.01em !important;
// Icon inside button — vertically center it and size it correctly
mat-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
font-size: 18px !important;
line-height: 1 !important;
vertical-align: middle !important;
margin: 0 !important;
flex: none;
}
mat-spinner {
flex: none;
}
}
// Stroked cancel button
[mat-stroked-button] {
border-color: $border-color !important;
color: $text-sub !important;
&:hover {
border-color: #94a3b8 !important;
background: $bg-page !important;
}
}
// Primary action button (enabled)
[mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important;
box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important;
color: #fff !important;
&:hover {
box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important;
transform: translateY(-1px) !important;
}
&:active {
transform: translateY(0) !important;
}
}
// Primary action button (disabled)
[mat-flat-button][color='primary'][disabled] {
background: #e2e8f0 !important;
color: #94a3b8 !important;
box-shadow: none !important;
}
}
/* ── Form fields (in search popup) ─────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
> span {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-muted;
}
select,
input {
padding: 0.55rem 0.7rem;
border: 1.5px solid $border-color;
border-radius: 7px;
font-size: 0.82rem;
color: $text-main;
background: #fff;
outline: none;
transition:
border-color 0.14s,
box-shadow 0.14s;
&:hover {
border-color: #94a3b8;
}
&:focus {
border-color: $accent;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
}
}
/* ── Sub-popups ─────────────────────────────────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgb(15 23 42 / 55%);
backdrop-filter: blur(2px);
}
.popup {
display: flex;
flex-direction: column;
width: 100%;
max-width: 460px;
max-height: 80vh;
border-radius: 12px;
background: #fff;
overflow: hidden;
box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%);
animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes popup-in {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.popup-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 0.85rem 0.85rem 1.2rem;
border-bottom: 1px solid $border-color;
background: $bg-page;
h3 {
flex: 1;
margin: 0;
font-size: 0.92rem;
font-weight: 700;
color: $text-main;
}
}
.popup-body {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.2rem;
overflow-y: auto;
}
.popup-foot {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding: 0.8rem 1.2rem;
border-top: 1px solid $border-color;
background: $bg-page;
}
/* ── Receipt detail popup ───────────────────────────────────────────────── */
.detail-list {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
> div {
display: flex;
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
}
dt {
flex: none;
width: 110px;
padding: 0.6rem 0.9rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $text-muted;
background: $bg-page;
border-right: 1px solid $border-color;
}
dd {
flex: 1;
min-width: 0;
margin: 0;
padding: 0.6rem 0.9rem;
font-size: 0.8rem;
color: $text-main;
word-break: break-word;
}
}
.detail-heading {
margin: 0.4rem 0 0.1rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-muted;
}
.gl-table {
width: 100%;
border-collapse: collapse;
font-size: 0.76rem;
border: 1px solid $border-color;
border-radius: 7px;
overflow: hidden;
th,
td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid $border-color;
text-align: left;
}
tbody tr:last-child td {
border-bottom: none;
}
th {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $text-muted;
background: $bg-page;
}
tbody tr:hover {
background: $accent-soft;
}
.num {
text-align: right;
}
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 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/.
*/
/**
* One Share Collection receipt the counter can choose, in a single shape whatever it came from.
*
* {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the
* member's own receipts under {@code own}, and every other receipt still going spare under
* {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list
* of vouchers instead of two tables whose difference a counter has to understand before they can
* pick anything.
*
* Shared by the two screens that settle a share purchase (the first application's Apply Collection
* dialog and the Apply Additional Shares dialog) so a receipt reads identically on both.
*/
export interface ReceiptOption {
/** Stable identity for tracking and comparison; unique across both source lists. */
key: string;
/**
* The {@code m_share_advance_collection} row behind this receipt, or null while it is still only
* a voucher number. A null one is attached to the member on first pick.
*/
collectionId: number | null;
voucherNo: string;
glAccountNo: string;
glAccountName: string;
date: string;
amount: number;
description: string;
cashierName: string;
}
/**
* A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is.
*
* Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by
* number arrives here too, already attached.
*/
export function receiptFromOwn(row: any): ReceiptOption {
return {
key: `c${row.id}`,
collectionId: Number(row.id),
voucherNo: row.voucher_no || row.receipt_no || '',
glAccountNo: row.gl_code || '',
glAccountName: '',
date: String(row.collection_date || '').slice(0, 10),
amount: Number(row.amount || 0),
description: row.note || '',
cashierName: row.collected_by_name || ''
};
}
/** A receipt that is still only a voucher, attached to the member when it is picked. */
export function receiptFromUnattached(row: any): ReceiptOption {
return {
key: `v${row.id}`,
collectionId: null,
voucherNo: row.voucherNo || '',
glAccountNo: row.glAccountNo || '',
glAccountName: row.glAccountName || '',
date: String(row.txnDate || '').slice(0, 10),
amount: Number(row.amount || 0),
description: row.description || '',
cashierName: row.cashierName || ''
};
}
/**
* The GL account a receipt sits in, named where the name is known and coded where it is not.
*
* An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL
* account list the screen already loaded for its voucher search rather than fetched again.
*/
export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string {
const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || '';
if (!option.glAccountNo) {
return name || '—';
}
return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,155 +6,116 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { of } from 'rxjs';
/** Custom Services */
import { SharesService } from 'app/shares/shares.service';
import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
/** Where the money for this tranche comes from. */
export type FundingSource = 'CASH' | 'SAVINGS';
/**
* Apply Additional Shares.
*
* A member buying a further tranche of shares, which — like the account's first application — is
* only a request until it is approved. Two things have to be captured that the bare share count
* does not carry:
*
* <ul>
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
* own number, not the one the account was opened under;</li>
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
* </ul>
* A member buying a further tranche, which — like the account's first application — is only a
* request until it is approved. Two things have to be captured that the bare share count does not
* carry: the board resolution authorising this particular tranche (its own meeting, its own number,
* not the one the account was opened under), and where the money is coming from, because a tranche
* nobody has paid for cannot be approved.
*
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
* Dr Sundry / Cr Share Capital pair either way:
*
* <ul>
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
* More than one can be ticked, because a member often pays in instalments and no single
* receipt covers the tranche. They are checked here rather than at approval, while the member
* is still at the counter to make up any shortfall.</li>
* <li><b>Cash</b> — the member already paid at the counter and the cashier's receipt put the money
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
* funds one tranche; it is checked here rather than at approval, while the member is still at
* the counter to make up any shortfall.</li>
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
* drops immediately. Approval then moves it out to share capital.</li>
* </ul>
*
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
* person, and two different designs for that were only ever two things to learn.
*/
@Component({
selector: 'mifosx-apply-shares',
templateUrl: './apply-shares.component.html',
styleUrls: ['./apply-shares.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatRadioModule,
MatProgressSpinnerModule,
MatIconModule
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplySharesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private sharesService = inject(SharesService);
private cashierService = inject(CashierService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/** Shares account data. */
sharesAccountData: any;
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
public data = inject<{
shareAccountId: number;
clientId: number;
accountNo: string;
unitPrice: number;
}>(MAT_DIALOG_DATA);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Apply Share Account form. */
applySharesForm: UntypedFormGroup;
/** Shares Account Id */
accountId: any;
/** Submission in flight, so the form cannot be posted twice. */
submitting = false;
/** Anything that stopped the submission, shown above the buttons. */
errorMsg = '';
/**
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
*
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
* rather than sending the member back to the counter for a single combined receipt.
*/
vouchers: any[] = [];
selectedVoucherIds: number[] = [];
loadingVouchers = false;
lookingUpVoucher = false;
voucherError = '';
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
/**
* Whether the voucher-search popup is open.
*
* The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and
* this member's own list do not carry — so it sits behind a button rather than on screen whether
* or not anyone needs it, exactly like the receipt-detail popup already does for the same reason.
*/
voucherSearchOpen = false;
/** How many shares this tranche is for. */
requestedShares: number | null = null;
openVoucherSearch(): void {
this.voucherError = '';
this.voucherSearchOpen = true;
/** The product's current market price. Read-only: the tranche's value and its GL posting must
* never be able to disagree because somebody typed over the price. */
unitPrice = 0;
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
requestedDate = '';
resolutionDate = '';
resolutionNumber = '';
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
return (Number(this.requestedShares) || 0) * this.unitPrice;
}
closeVoucherSearch(): void {
this.voucherSearchOpen = false;
this.voucherError = '';
}
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
/** 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 = '';
funding: FundingSource | null = null;
/**
* 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[] = [];
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
available: ReceiptOption[] = [];
loadingReceipts = false;
/** 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. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** The one receipt funding this tranche. One receipt, one tranche. */
receipt: ReceiptOption | null = null;
/** Currently selected voucher for viewing in the detail popup. */
viewingVoucherDetail: any = null;
/** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account
* dropdown above (that is just what the by-voucher search box is currently set to, which can
* name a completely different account than the one this specific receipt actually sits in). */
voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = [];
loadingGlEntries = false;
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
attaching: string | null = null;
/** What the member's savings can cover, for the savings route. */
savingsEligibility: {
savings: {
eligible: boolean;
reason: string | null;
savingsAccountNo: string | null;
@@ -162,314 +123,136 @@ export class ApplySharesComponent implements OnInit {
} | null = null;
checkingSavings = false;
constructor() {
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
this.sharesAccountData = data.shareAccountActionData;
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
* "1000" — which asks four times and lets the answers land out of order. */
private sharesChanged = new Subject<number>();
ngOnInit(): void {
this.unitPrice = Number(this.data.unitPrice) || 0;
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
// asking a clerk to type that in is asking them to retype what the system already knows.
this.requestedDate = this.isoOf(this.settingsService.businessDate);
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
if (this.funding === 'SAVINGS') {
this.checkSavings();
}
});
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createApplySharesAccountForm();
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
this.applySharesForm.get('amount').patchValue(this.amount || '');
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
// background so opening that popup never waits on a request.
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(catchError(() => of(null)))
.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: picking "whichever
// sorts first" among several real candidates is the same blind guess that used to land on
// Cash at Vault, just with a shorter, filtered list to guess from.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false;
});
}
setPaymentMode(mode: string): void {
this.applySharesForm.get('paymentMode')?.setValue(mode);
onSharesChange(): void {
// The verdict is cleared the moment the amount changes rather than when the re-check returns, so
// a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays --
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
// new amount on its own.
this.savings = null;
this.errorMsg = '';
this.sharesChanged.next(Number(this.requestedShares) || 0);
}
openVoucherDetail(voucher: any, event?: Event): void {
if (event) {
event.stopPropagation();
chooseFunding(source: FundingSource): void {
if (this.funding === source) {
return;
}
this.funding = source;
this.receipt = null;
this.savings = null;
this.errorMsg = '';
if (source === 'SAVINGS') {
this.checkSavings();
} else if (!this.available.length) {
this.loadReceipts();
}
this.viewingVoucherDetail = voucher;
this.voucherGlEntries = [];
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(voucher.id)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.voucherGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeVoucherDetail(): void {
this.viewingVoucherDetail = null;
this.voucherGlEntries = [];
}
/**
* Creates the apply shares form.
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
* ones still belonging to nobody — as one list.
*
* The request date opens on the business date rather than blank: an additional-share request is
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
* retype what the system already knows.
*/
createApplySharesAccountForm() {
this.applySharesForm = this.formBuilder.group({
requestedDate: [
this.settingsService.businessDate,
Validators.required
],
requestedShares: [
'',
Validators.required
],
unitPrice: [{ value: '', disabled: true }],
// Disabled, not readonly: the amount is derived from shares x price and must never be a
// figure somebody typed, or the tranche's value and its GL posting could disagree.
amount: [{ value: '', disabled: true }],
resolutionNumber: [
'',
Validators.required
],
resolutionDate: [
'',
Validators.required
],
paymentMode: [
'',
Validators.required
]
});
// Recompute as the share count is typed, so the clerk can read the amount back to the member
// before committing to a funding route.
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
this.applySharesForm.get('amount').patchValue(this.amount || '');
// A savings verdict is cleared the moment the amount changes rather than when the re-check
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
// vouchers stay: they are the receipts the member actually holds, and the running total below
// shows against the new amount whether they still cover it.
this.voucherError = '';
this.savingsEligibility = null;
});
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
// on every keystroke of "1000" asks four times and lets the answers land out of order.
this.applySharesForm
.get('requestedShares')
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe(() => {
if (this.paymentMode === 'SAVINGS') {
this.checkSavings();
}
});
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
this.vouchers = [];
this.selectedVoucherIds = [];
this.voucherError = '';
this.voucherNo = '';
this.errorMsg = '';
this.savingsEligibility = null;
if (mode === 'SAVINGS') {
this.checkSavings();
} else if (mode === 'CASH') {
this.loadVouchers();
}
});
}
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
get amount(): number {
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
return shares * price;
}
get paymentMode(): string {
return this.applySharesForm?.get('paymentMode')?.value || '';
}
/**
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
*
* 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
* Nothing is pre-selected, 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 row
* they never looked at.
*/
private loadVouchers(): void {
this.loadingVouchers = true;
private loadReceipts(): void {
this.loadingReceipts = true;
this.cashierService
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
.getShareCollectionReceipts(this.data.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
this.loadingVouchers = false;
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
this.loadingReceipts = false;
return of(null);
})
)
.subscribe((res: any) => {
this.loadingVouchers = false;
this.loadingReceipts = false;
if (!res) {
return;
}
this.vouchers = res.unappliedCollections || [];
});
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
* current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id.
*/
toggleVoucher(id: number): void {
const wasSelected = this.isVoucherSelected(id);
this.selectedVoucherIds = wasSelected ? [] : [id];
this.voucherError = '';
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 || '';
}
/**
* What the ticked receipts come to.
*
* Shown live, because the submission only goes through when the total covers the tranche, and
* adding the rows up by eye is exactly what leads to a rejected submission.
*/
get selectedVoucherTotal(): number {
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
}
/**
* 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 not
* restricted to this member — any receipt found this way can be attached here, whichever member it
* was originally recorded against.
*/
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(voucherNo, glAccountNo, this.sharesAccountData.clientId)
.pipe(
catchError((err: any) => {
this.voucherError = this.readError(err, `Voucher ${voucherNo} could not be found.`);
this.lookingUpVoucher = false;
return of(null);
})
)
.subscribe((res: any) => {
this.lookingUpVoucher = false;
if (!res) {
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 = [
res,
...this.vouchers.filter((v) => v.id !== res.id)
this.available = [
...(res.own || []).map((row: any) => receiptFromOwn(row)),
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
];
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);
// Found it -- the popup has done its job, so it closes rather than sitting open over a
// receipt that now already shows, ticked, in the table behind it.
this.voucherSearchOpen = false;
});
}
/**
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
* a voucher number.
*
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it has to be attributed before it can fund anything. That attribution is exactly the
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
* choice against a member on screen, rather than an Attach in one table and a tick in another.
*/
pick(option: ReceiptOption): void {
if (this.attaching || this.submitting) {
return;
}
if (this.receipt?.key === option.key) {
this.receipt = null;
return;
}
if (option.collectionId !== null) {
this.receipt = option;
return;
}
this.attaching = option.key;
this.errorMsg = '';
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
this.attaching = null;
if (!res) {
return;
}
// Attached now, so it takes its real row's place in the pool -- picking it again must never
// run the attach a second time.
const attached = receiptFromOwn(res);
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
this.receipt = attached;
});
}
clearReceipt(): void {
this.receipt = null;
}
/** What the chosen receipt is still missing, zero once it covers the tranche. */
get shortfall(): number {
const short = this.amount - Number(this.receipt?.amount || 0);
return this.receipt && short > 0 ? short : 0;
}
/**
* Whether the member's savings can cover this tranche.
*
@@ -478,104 +261,215 @@ export class ApplySharesComponent implements OnInit {
* submission will be valued at. Deciding it in the browser would let the two drift apart.
*/
private checkSavings(): void {
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
const shares = Number(this.requestedShares) || 0;
this.checkingSavings = true;
this.sharesService
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.checkingSavings = false;
if (!res) {
this.savingsEligibility = {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
return;
}
this.savingsEligibility = {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
};
this.savings = res
? {
eligible: !!res.eligible,
reason: res.reason ?? null,
savingsAccountNo: res.savingsAccountNo ?? null,
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
}
: {
eligible: false,
reason: 'Could not check the savings account linked to this share account.',
savingsAccountNo: null,
savingsBalance: null
};
});
}
/**
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
* savings that can cover it.
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
* can.
*
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
* at the counter instead of being refused after the member has left.
* Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter
* instead of being refused after the member has left.
*/
get fundingReady(): boolean {
if (this.paymentMode === 'CASH') {
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
if (this.funding === 'CASH') {
return !!this.receipt && this.shortfall === 0;
}
if (this.paymentMode === 'SAVINGS') {
return !!this.savingsEligibility?.eligible;
if (this.funding === 'SAVINGS') {
return !!this.savings?.eligible;
}
return false;
}
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
get voucherShortfall(): number {
const short = this.amount - this.selectedVoucherTotal;
return short > 0 ? short : 0;
get canSubmit(): boolean {
return (
this.amount > 0 &&
!!this.requestedDate &&
!!this.resolutionNumber.trim() &&
!!this.resolutionDate &&
this.fundingReady &&
!this.submitting
);
}
get canSubmit(): boolean {
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searchOpen = false;
searchVoucherNo = '';
searchGlAccountNo = '';
searching = false;
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
glAccounts: any[] = [];
loadingGlAccounts = true;
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
const glAccountNo = this.searchGlAccountNo.trim();
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
// not that none is required.
if (!voucher || !glAccountNo || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.searching = false;
if (!res) {
return;
}
const found = receiptFromOwn(res);
this.available = [
found,
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
];
this.receipt = found;
this.searchOpen = false;
});
}
private findVoucher(voucher: string, glAccountNo: string) {
// No client-match check: any receipt can fund any share account, regardless of which member it
// was first recorded against.
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
catchError((err: any) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
return of(null);
})
);
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: ReceiptOption | null = null;
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
* picked in the search box, which is unrelated screen state naming a different account. */
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: ReceiptOption, event: Event): void {
event.stopPropagation();
if (option.collectionId === null) {
return;
}
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(option.collectionId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
/**
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
* which is what moves the money out of Sundry into share capital.
*/
submit() {
submit(): void {
if (!this.canSubmit) {
return;
}
this.submitting = true;
this.errorMsg = '';
const formValue = this.applySharesForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data: any = {
requestedShares: formValue.requestedShares,
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
unitPrice: this.applySharesForm.get('unitPrice').value,
resolutionNumber: formValue.resolutionNumber,
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
fundingSource: formValue.paymentMode,
const payload: any = {
requestedShares: this.requestedShares,
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
unitPrice: this.unitPrice,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
fundingSource: this.funding,
dateFormat,
locale
locale: this.settingsService.language.code
};
if (formValue.paymentMode === 'CASH') {
// First receipt leads, the rest ride along; the server settles them as one funding.
const [
leadId,
...additionalCollectionIds
] = this.selectedVoucherIds;
data.advanceCollectionId = leadId;
if (additionalCollectionIds.length > 0) {
data.additionalCollectionIds = additionalCollectionIds;
}
if (this.funding === 'CASH' && this.receipt) {
payload.advanceCollectionId = this.receipt.collectionId;
}
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
this.sharesService
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
.subscribe({
next: (res: any) => {
this.dialogRef.close(res || true);
},
error: (err: any) => {
this.submitting = false;
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
}
});
}
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
glLabel(option: ReceiptOption): string {
return receiptGlLabel(option, this.glAccounts);
}
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
private isoOf(date: Date): string {
const local = new Date(date);
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
}
/**
* A yyyy-MM-dd back into a local Date.
*
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
* not something a date picker should be able to produce.
*/
private dateOf(iso: string): Date {
const [
year,
month,
day
] = iso.split('-').map(Number);
return new Date(year, month - 1, day);
}
private readError(err: any, fallback: string): string {
@@ -21,9 +21,6 @@
@if (actions['Undo Approval']) {
<mifosx-undo-approval-shares-account></mifosx-undo-approval-shares-account>
}
@if (actions['Apply Additional Shares']) {
<mifosx-apply-shares></mifosx-apply-shares>
}
@if (actions['Redeem Shares']) {
<mifosx-redeem-shares></mifosx-redeem-shares>
}
@@ -17,7 +17,6 @@ import { RejectSharesAccountComponent } from './reject-shares-account/reject-sha
import { CloseSharesAccountComponent } from './close-shares-account/close-shares-account.component';
import { ActivateSharesAccountComponent } from './activate-shares-account/activate-shares-account.component';
import { UndoApprovalSharesAccountComponent } from './undo-approval-shares-account/undo-approval-shares-account.component';
import { ApplySharesComponent } from './apply-shares/apply-shares.component';
import { RedeemSharesComponent } from './redeem-shares/redeem-shares.component';
import { ApproveSharesComponent } from './approve-shares/approve-shares.component';
import { RejectSharesComponent } from './reject-shares/reject-shares.component';
@@ -37,7 +36,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
CloseSharesAccountComponent,
ActivateSharesAccountComponent,
UndoApprovalSharesAccountComponent,
ApplySharesComponent,
RedeemSharesComponent,
ApproveSharesComponent,
RejectSharesComponent
@@ -55,7 +53,6 @@ export class SharesAccountActionsComponent {
Close: boolean;
Activate: boolean;
'Undo Approval': boolean;
'Apply Additional Shares': boolean;
'Redeem Shares': boolean;
'Approve Additional Shares': boolean;
'Reject Additional Shares': boolean;
@@ -65,7 +62,6 @@ export class SharesAccountActionsComponent {
Close: false,
Activate: false,
'Undo Approval': false,
'Apply Additional Shares': false,
'Redeem Shares': false,
'Approve Additional Shares': false,
'Reject Additional Shares': false
@@ -7,408 +7,270 @@
-->
<div class="dialog-wrapper">
<!-- Dialog Header Banner -->
<div class="dialog-header-banner">
<div class="header-icon-box">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text font-accent">
<h2>Apply Share Advance Collection</h2>
<div class="header-text">
<h2>Apply Share Collection</h2>
<p>
Settling share account <strong class="font-mono">{{ data.accountNo }}</strong>
Share account <strong class="mono">{{ data.accountNo }}</strong>
</p>
</div>
<button type="button" mat-icon-button class="close-header-btn" [mat-dialog-close]="false">
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
<!-- Intro Banner -->
<div class="dialog-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
Tick the receipts that make up this payment to settle share account <strong>{{ data.accountNo }}</strong
>. Multiple receipts can be selected for instalment payments. Click
<mat-icon class="inline-icon">visibility</mat-icon> to view full receipt popup details.
</span>
</div>
<!-- Requirement Summary Stat Card -->
@if (requirement) {
<div class="requirement-summary-card">
<div class="req-header">
<mat-icon class="req-icon">calculate</mat-icon>
<span>Tranche Requirement</span>
</div>
<div class="req-metrics font-accent">
<div class="metric-item">
<span class="m-label">Shares Value</span>
<span class="m-val font-mono">{{ requirement.shareAmount | number: '1.2-2' }}</span>
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading receipts…</span>
</div>
} @else {
@if (requirement) {
<div class="sc-card">
<!-- Share row -->
<div
class="sc-row"
[class.sc-ok]="shareReceipt && shareShortfall === 0"
[class.sc-bad]="shareReceipt && shareShortfall > 0"
>
<div class="sc-left">
<span class="sc-label">Share Collection</span>
<span class="sc-sub"
>Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b></span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="shareReceipt && shareShortfall === 0"
[class.sc-val-bad]="shareReceipt && shareShortfall > 0"
>
{{ shareReceipt ? (shareReceipt.amount | number: '1.2-2') : '—' }}
</span>
@if (shareShortfall > 0) {
<span class="sc-tag sc-tag-bad">-{{ shareShortfall | number: '1.2-2' }}</span>
} @else if (shareReceipt && shareShortfall === 0) {
<span class="sc-tag sc-tag-ok">✓ Covered</span>
}
</div>
</div>
@if (requirement.chargeAmount > 0) {
<span class="metric-plus">+</span>
<div class="metric-item">
<span class="m-label">Entry Fee</span>
<span class="m-val font-mono">{{ requirement.chargeAmount | number: '1.2-2' }}</span>
<!-- Entry Fee row -->
@if (hasFee) {
<div
class="sc-row sc-sep"
[class.sc-ok]="chargeReceipt && feeShortfall === 0"
[class.sc-bad]="chargeReceipt && feeShortfall > 0"
>
<div class="sc-left">
<span class="sc-label">Entry Fee</span>
<span class="sc-sub"
>Required: <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b></span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="chargeReceipt && feeShortfall === 0"
[class.sc-val-bad]="chargeReceipt && feeShortfall > 0"
>
{{ chargeReceipt ? (chargeReceipt.amount | number: '1.2-2') : '—' }}
</span>
@if (feeShortfall > 0) {
<span class="sc-tag sc-tag-bad">-{{ feeShortfall | number: '1.2-2' }}</span>
} @else if (chargeReceipt && feeShortfall === 0) {
<span class="sc-tag sc-tag-ok">✓ Covered</span>
}
</div>
</div>
}
<span class="metric-equals">=</span>
<div class="metric-item highlight-total">
<span class="m-label">Total Required</span>
<span class="m-val font-mono font-semibold">{{ requirement.required | number: '1.2-2' }}</span>
</div>
</div>
@if (selectedIds.length > 0) {
<div class="req-status-bar mt-6">
@if (shortfall === 0) {
<span class="status-badge badge-success">
<mat-icon>check_circle</mat-icon> Share receipts fully covered
<!-- Total row -->
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total Payable</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ requirement.required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">
{{
(shareReceipt?.amount || 0) + (hasFee && chargeReceipt ? chargeReceipt.amount || 0 : 0)
| number: '1.2-2'
}}
</span>
} @else {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> {{ selectedTotal | number: '1.2-2' }} of
{{ shareRequired | number: '1.2-2' }} collected — {{ shortfall | number: '1.2-2' }} short
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
}
</div>
}
</div>
}
<!-- SHARE RECEIPT LOOKUP — a voucher not already sitting in the lists below (an older receipt,
or a member's own slip) is reached through the popup, not a toolbar that sat here whether or
not anyone needed it. The unattributed list and the member's own receipts already cover the
ordinary case; this is only for the exception. -->
<div class="search-section">
<button
mat-stroked-button
type="button"
class="voucher-search-toggle-btn"
[disabled]="submitting"
(click)="openVoucherSearch()"
>
<mat-icon>search</mat-icon>
<span>Search a Voucher Not Listed Below</span>
</button>
</div>
<!-- ENTRY FEE RECEIPT SECTION (If charge exists) -->
@if (requirement && requirement.chargeAmount > 0) {
<div class="search-section fee-section">
<div class="section-label">
<mat-icon class="sec-icon font-warning">assignment_turned_in</mat-icon>
<span>Entry Fee Receipt — Verification Only (No GL Entry)</span>
</div>
@if (chargeReceipt) {
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle' }}</mat-icon>
<span class="charge-receipt-detail font-accent">
<b class="font-mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
— {{ chargeReceipt.amount | number: '1.2-2' }}
@if (chargeShortfall > 0) {
<span class="short-text">(short by {{ chargeShortfall | number: '1.2-2' }})</span>
} @else {
<span class="verified-text">— Entry fee verified</span>
}
</span>
<button
mat-stroked-button
type="button"
class="change-btn"
[disabled]="submitting"
(click)="clearChargeReceipt()"
>
Change
</button>
</div>
} @else {
<div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> Fee GL Account</label>
<select
class="custom-select"
[(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
>
<option value="" disabled>
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup-box">
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Fee Voucher No</label>
<div class="lookup-input-group">
<input
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()"
>
@if (lookingUpCharge) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div>
</div>
</div>
<div class="charge-receipt-hint">
Leave this blank if the entry fee was already paid as part of the main share receipt above.
</div>
}
</div>
}
<!-- UNATTRIBUTED RECEIPTS SECTION -->
@if (unattributed.length) {
<div class="unattributed-section">
<div class="section-label text-amber">
<mat-icon class="sec-icon">person_add</mat-icon>
<span
>Unattached Share Collection Receipts — Attach to <strong>{{ data.accountNo }}</strong></span
>
</div>
<div class="table-responsive">
<table class="receipt-table unattributed-table">
<thead>
<tr>
<th class="ta-left">Voucher No</th>
<th class="ta-left">Date</th>
<th class="ta-left">Deposited To</th>
<th class="ta-left">Description</th>
<th class="ta-right">Amount</th>
<th class="col-attach ta-center">Action</th>
</tr>
</thead>
<tbody>
@for (u of unattributed; track u.id) {
<tr [class.own-client-row]="isOwnClientVoucher(u)">
<td class="ta-left">
<span class="voucher-badge font-mono">{{ u.voucherNo }}</span>
@if (isOwnClientVoucher(u)) {
<span class="own-client-badge">This member's receipt</span>
}
</td>
<td class="ta-left">{{ u.txnDate }}</td>
<td class="ta-left">{{ u.glAccountNo }} — {{ u.glAccountName }}</td>
<td class="ta-left desc-cell" [title]="u.description">{{ u.description || '—' }}</td>
<td class="ta-right amount-cell">{{ u.amount | number: '1.2-2' }}</td>
<td class="ta-center col-attach">
<button
mat-flat-button
color="accent"
type="button"
class="attach-btn"
[disabled]="lookingUp || submitting"
(click)="attachUnattributed(u)"
>
Attach
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (errorMsg) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- RECEIPTS ARRAY LIST -->
<div class="vouchers-array-container mt-8">
@if (loading) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading member receipts…</span>
</div>
} @else if (receipts.length === 0) {
<div class="funding-empty">
<mat-icon class="empty-icon">receipt_long</mat-icon>
<div class="empty-text">
<strong>No unapplied receipt found for this member today.</strong>
<p>
If cash was taken on an earlier day, enter that voucher number above. Otherwise collect cash first at
<em>Cashier &rsaquo; Transaction Entry</em> into Share Sundry GL.
</p>
</div>
</div>
} @else {
<div class="vouchers-card">
<!-- Array Header Toolbar -->
<div class="vouchers-header">
<div class="vouchers-title font-accent">
<mat-icon class="v-icon">receipt</mat-icon>
<span>Member Unapplied Receipts</span>
<span class="badge-count">{{ receipts.length }}</span>
</div>
@if (receipts.length > 1) {
<div class="select-all-action">
<button type="button" mat-button class="select-all-btn" (click)="toggleAll()">
<mat-icon>{{
isAllSelected()
? 'check_box'
: isSomeSelected()
? 'indeterminate_check_box'
: 'check_box_outline_blank'
}}</mat-icon>
<span
>{{ isAllSelected() ? 'Deselect All' : 'Select All' }} ({{ selectedIds.length }}/{{
receipts.length
}})</span
>
</button>
</div>
}
</div>
<!-- Table View -->
<div class="table-responsive">
<table class="receipt-table">
<thead>
<tr>
<th class="col-checkbox ta-center">
@if (receipts.length > 1) {
<input
type="checkbox"
class="custom-checkbox"
[checked]="isAllSelected()"
[indeterminate]="isSomeSelected()"
(change)="toggleAll()"
/>
} @else {
<span>#</span>
}
</th>
<th class="ta-left">Voucher / Receipt No</th>
<th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th>
<th class="col-actions ta-center">Detail</th>
</tr>
</thead>
<tbody>
@for (r of receipts; track r.id) {
<tr class="receipt-row" [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<input
type="checkbox"
class="custom-checkbox"
[checked]="isSelected(r.id)"
(change)="toggle(r.id)"
/>
</td>
<td class="ta-left">
<span class="voucher-badge font-mono">{{ r.voucher_no || r.receipt_no }}</span>
</td>
<td class="ta-left date-cell">{{ r.collection_date }}</td>
<td class="ta-left desc-cell" [title]="r.note">{{ r.note || 'Share Collection' }}</td>
<td class="ta-left user-cell">
<span class="user-chip">
<mat-icon class="chip-icon">person_outline</mat-icon>
{{ r.collected_by_name || 'System' }}
</span>
</td>
<td class="ta-right amount-cell">
<span class="val font-semibold">{{ r.amount | number: '1.2-2' }}</span>
</td>
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
<button
type="button"
mat-icon-button
class="view-detail-btn"
title="View Full Details Popup"
(click)="openReceiptDetail(r, $event)"
>
<mat-icon class="eye-icon">visibility</mat-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Summary Footer -->
<div class="vouchers-footer">
<div class="selected-summary">
<span class="summary-text font-accent">
<strong>{{ selectedIds.length }}</strong> of <strong>{{ receipts.length }}</strong> receipts selected
</span>
<div class="summary-amounts">
<span class="total-label">Selected Total:</span>
<span class="total-value font-semibold">{{ selectedTotal | number: '1.2-2' }}</span>
<span class="target-divider">/</span>
<span class="target-value">Required {{ shareRequired | number: '1.2-2' }}</span>
</div>
</div>
<div class="coverage-status">
@if (selectedIds.length === 0) {
<span class="status-badge badge-neutral"> <mat-icon>info</mat-icon> Tick at least 1 receipt </span>
} @else if (shortfall > 0) {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> Shortfall {{ shortfall | number: '1.2-2' }}
</span>
} @else {
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Covered </span>
}
</div>
</div>
</div>
}
</div>
<!-- ── Slot 1: the share receipt ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Share Collection Receipt</h3>
<p>Posts Dr Sundry / Cr Share Capital.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch('share')">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (shareReceipt) {
<div class="chosen" [class.chosen-short]="shareShortfall > 0">
<div class="chosen-badge">
<mat-icon>{{ shareShortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher mono">{{ shareReceipt.voucherNo }}</span>
<span class="chosen-gl">{{ glLabel(shareReceipt) }}</span>
</div>
<span class="chosen-amount mono">{{ shareReceipt.amount | number: '1.2-2' }}</span>
<button type="button" class="chosen-clear" (click)="clear('share')" aria-label="Change receipt">
<mat-icon>close</mat-icon>
</button>
</div>
@if (shareShortfall > 0) {
<p class="slot-note note-bad">
Short by {{ shareShortfall | number: '1.2-2' }} — this receipt does not cover
{{ shareRequired | number: '1.2-2' }}.
</p>
} @else if (requirement) {
<p class="slot-note note-good">Covers the {{ shareRequired | number: '1.2-2' }} payable.</p>
}
} @else {
@if (optionsFor('share').length) {
<ul class="options">
@for (option of optionsFor('share'); track option.key) {
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick('share', option)">
<span class="option-radio"></span>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
@if (attaching === option.key) {
<mat-spinner diameter="16"></mat-spinner>
} @else if (option.collectionId !== null) {
<button
type="button"
class="option-info"
(click)="openDetail(option, $event)"
aria-label="GL entries"
>
<mat-icon>receipt_long</mat-icon>
</button>
}
</li>
}
</ul>
} @else {
<p class="slot-empty">
No Share Collection receipt is waiting. Collect the cash at <em>Cashier › Transaction Entry</em> with
entry type <strong>Share Collection</strong>, or find an earlier voucher by number.
</p>
}
}
</section>
<!-- ── Slot 2: the entry fee receipt ─────────────────────────────────────── -->
@if (hasFee) {
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>Entry Fee Receipt</h3>
<p>Verification only — no GL entry is posted for it.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch('fee')">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (chargeReceipt) {
<div class="chosen" [class.chosen-short]="feeShortfall > 0">
<div class="chosen-badge">
<mat-icon>{{ feeShortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
</div>
<div class="chosen-main">
<span class="chosen-voucher mono">{{ chargeReceipt.voucherNo }}</span>
<span class="chosen-gl">{{ glLabel(chargeReceipt) }}</span>
</div>
<span class="chosen-amount mono">{{ chargeReceipt.amount | number: '1.2-2' }}</span>
<button type="button" class="chosen-clear" (click)="clear('fee')" aria-label="Change receipt">
<mat-icon>close</mat-icon>
</button>
</div>
@if (feeShortfall > 0) {
<p class="slot-note note-bad">
Short by {{ feeShortfall | number: '1.2-2' }} — the fee is
{{ requirement!.chargeAmount | number: '1.2-2' }}.
</p>
} @else {
<p class="slot-note note-good">Entry fee verified.</p>
}
} @else {
@if (optionsFor('fee').length) {
<ul class="options">
@for (option of optionsFor('fee'); track option.key) {
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick('fee', option)">
<span class="option-radio"></span>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
@if (attaching === option.key) {
<mat-spinner diameter="16"></mat-spinner>
} @else if (option.collectionId !== null) {
<button
type="button"
class="option-info"
(click)="openDetail(option, $event)"
aria-label="GL entries"
>
<mat-icon>receipt_long</mat-icon>
</button>
}
</li>
}
</ul>
} @else {
<p class="slot-empty">No separate fee receipt is waiting — find one by voucher number.</p>
}
<p class="slot-note note-muted">
Leave this empty if the fee was already collected inside the share receipt above.
</p>
}
</section>
}
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<!-- Dialog Actions Bar -->
<mat-dialog-actions align="end" class="dialog-actions-bar">
<button
mat-stroked-button
type="button"
class="action-btn cancel-btn"
[mat-dialog-close]="false"
[disabled]="submitting"
>
Cancel
</button>
<button
mat-flat-button
color="primary"
type="button"
class="action-btn submit-btn"
[disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
(click)="apply()"
>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
@@ -420,201 +282,140 @@
</mat-dialog-actions>
</div>
<!-- RECEIPT DETAIL POPUP MODAL -->
@if (viewingReceiptDetail) {
<div class="modal-backdrop animate-fade-in" (click)="closeReceiptDetail()">
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="header-title font-accent">
<mat-icon class="m-icon">receipt_long</mat-icon>
<span>Receipt Details</span>
<span class="modal-badge font-mono">{{
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no
}}</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeReceiptDetail()">
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchFor) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>{{ searchFor === 'share' ? 'Find a share receipt' : 'Find an entry fee receipt' }}</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
</header>
<div class="modal-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Voucher / Receipt No</span>
<span class="detail-val font-mono font-semibold">{{
viewingReceiptDetail.voucher_no || viewingReceiptDetail.receipt_no || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Receipt Reference</span>
<span class="detail-val font-mono">{{ viewingReceiptDetail.receipt_no || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client ID</span>
<span class="detail-val font-mono">{{ viewingReceiptDetail.client_id || data?.clientId || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collection Date</span>
<span class="detail-val">{{ viewingReceiptDetail.collection_date || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Status</span>
<span class="status-pill status-unapplied">{{ viewingReceiptDetail.status || 'UNAPPLIED' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collected By</span>
<span class="detail-val">{{ viewingReceiptDetail.collected_by_name || 'System' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">GL Entries — What This Receipt Actually Posted</span>
@if (loadingReceiptGlEntries) {
<div class="gl-entries-loading">
<mat-spinner diameter="16"></mat-spinner>
<span>Loading the ledger entries…</span>
</div>
} @else if (!viewingReceiptGlEntries?.length) {
<div class="gl-entries-empty">No posted GL entries found for this receipt yet.</div>
} @else {
<table class="gl-entries-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (e of viewingReceiptGlEntries; track e.glCode) {
<tr>
<td class="ta-left font-mono">{{ e.glCode }} — {{ e.accountName }}</td>
<td class="ta-center">
<span class="drcr-badge" [class.dr]="e.drCr === 'Dr'" [class.cr]="e.drCr === 'Cr'">{{
e.drCr
}}</span>
</td>
<td class="ta-right font-mono">{{ e.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<select [(ngModel)]="searchGlAccountNo" [disabled]="loadingGlAccounts">
<option value="">{{ loadingGlAccounts ? 'Loading…' : 'Select the account it was paid into' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</div>
</select>
</label>
<div class="detail-item full-width highlight-amount-box">
<span class="detail-label">Receipt Amount</span>
<span class="amount-val font-mono">{{ viewingReceiptDetail.amount | number: '1.2-2' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">Full Note / Description</span>
<div class="note-box font-accent">
{{ viewingReceiptDetail.note || 'Share collection deposit to Share Sundry GL' }}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
mat-flat-button
[color]="isSelected(viewingReceiptDetail.id) ? 'warn' : 'primary'"
class="modal-action-btn"
(click)="toggle(viewingReceiptDetail.id); closeReceiptDetail()"
>
<mat-icon>{{
isSelected(viewingReceiptDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
}}</mat-icon>
<span>{{ isSelected(viewingReceiptDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeReceiptDetail()">Close</button>
</div>
</div>
</div>
}
<!-- SEARCH VOUCHER POPUP -->
@if (voucherSearchOpen) {
<div class="modal-backdrop animate-fade-in" (click)="closeVoucherSearch()">
<div class="voucher-modal-dialog voucher-search-dialog" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="header-title font-accent">
<mat-icon class="m-icon">search</mat-icon>
<span>Search a Voucher</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeVoucherSearch()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="modal-body">
<p class="voucher-search-hint">
For a receipt not already listed above — one taken on an earlier day, or a slip the member is holding.
</p>
<div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"><mat-icon class="mini-icon">account_tree</mat-icon> GL Account</label>
<select
class="custom-select"
[(ngModel)]="glAccountNo"
[disabled]="lookingUp || submitting || loadingGlAccounts"
>
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup-box">
<label class="toolbar-label"><mat-icon class="mini-icon">receipt</mat-icon> Voucher / Receipt No</label>
<div class="lookup-input-group">
<input
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
[(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()"
>
@if (lookingUp) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div>
</div>
</div>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/18092026/0004"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert-box alert-error">
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<div class="modal-footer">
<button type="button" mat-stroked-button (click)="closeVoucherSearch()">Close</button>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || !searchGlAccountNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.date }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track $index) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.accountName }}
</td>
<td class="num mono">{{ entry.drCr === 'Dr' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'Cr' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -16,23 +16,29 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
/** Which of the two receipt slots a pick, a search or a clear is meant for. */
export type ReceiptSlot = 'share' | 'fee';
/**
* Applies a Share Advance receipt to an activated share account.
* Applies Share Advance receipts to an activated share account.
*
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
* The member's cash is already sitting in the Share Sundry liability from the cashier's collection.
* Settling here posts Dr Sundry / Cr Share Capital and reveals the purchase in the transactions and
* the passbook.
*
* The list shows the member's unapplied receipts for the business date, since a member settling an
* application at the counter has almost always paid for it today, and an older unapplied receipt of
* theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see
* {@link ngOnInit}.
* Two receipts settle an application and no more: one for the share value, and — when the product
* charges one — one for the entry fee. They are chosen in two separate slots because they are two
* different things: the share receipt is money that moves into share capital, while the fee receipt
* only proves the fee was already collected and is never posted a second time (its money was
* recognised as income by the cashier's own entry). Picking both out of one ticked list was what
* made that distinction invisible.
*
* A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} —
* cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find.
* Every available receipt is offered in both slots, whether it already names this member or is a
* voucher paid straight into a GL account. A voucher that names nobody yet is attached to this
* member at the moment it is picked, so the counter does an Attach-then-tick as a single click
* rather than as two steps in two different tables.
*/
@Component({
selector: 'mifosx-apply-collection-dialog',
@@ -50,138 +56,62 @@ import { Dates } from 'app/core/utils/dates';
})
export class ApplyCollectionDialogComponent implements OnInit {
private cashierService = inject(CashierService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
loading = true;
submitting = false;
errorMsg = '';
receipts: any[] = [];
/**
* Receipts chosen for this settlement, in the order they were picked.
*
* More than one because a member often pays an application in instalments — ₹4,500 today, the
* ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied
* together as a single settlement rather than forcing the member back to the counter for a fresh
* receipt covering the whole amount.
*/
selectedIds: number[] = [];
/** Voucher number quoted by the member, for a receipt the list does not show. */
voucherNo = '';
lookingUp = false;
/**
* Whether the voucher-search popup is open.
*
* The GL Account / Voucher No lookup is for the exception — a receipt the unattributed list and
* the member's own list do not carry — so it sits behind a button rather than on screen whether or
* not anyone needs it, exactly like the receipt-detail popup already does for the same reason.
*/
voucherSearchOpen = false;
openVoucherSearch(): void {
this.errorMsg = '';
this.voucherSearchOpen = true;
}
closeVoucherSearch(): void {
this.voucherSearchOpen = false;
this.errorMsg = '';
}
/** 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. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** A second, separately-tracked receipt search for the entry fee — a member sometimes pays the
* share value and the fee as two separate receipts, posted to two different GL accounts, so one
* voucher search covering only the share value would never reach the fee on its own.
*
* Kept entirely apart from {@link receipts}/{@link selectedIds}: this receipt is sent to the
* server as its own field (see {@link apply}) so it is never swept into the Sundry-side sum and
* never gets a GL entry of its own — it is proof the fee was paid, not money that needs to move,
* since it never sat in Sundry in the first place. */
chargeVoucherNo = '';
chargeGlAccountNo = '';
lookingUpCharge = false;
chargeReceipt: any = null;
/** What this account needs before a collection can be applied — fetched up front so the counter
* knows the full figure (and that it may take two receipts) before a search ever comes up short. */
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
loadingRequirement = true;
/**
* Share-collection receipts that are approved but belong to nobody yet.
*
* <p>
* 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<ApplyCollectionDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
loading = true;
submitting = false;
errorMsg = '';
/** Every receipt that can be picked, from both server lists, in one pool. */
available: ReceiptOption[] = [];
/** The receipt covering the share value. Required — nothing settles without it. */
shareReceipt: ReceiptOption | null = null;
/** The receipt covering the entry fee. Verification only: never posted to a GL account. */
chargeReceipt: ReceiptOption | null = null;
/** What this account needs before a collection can be applied, fetched up front. */
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
attaching: string | null = null;
ngOnInit(): void {
// One call for both lists -- the member's own receipts and every other Share Collection receipt
// still available. They come from the same table under the same filters, so asking twice only
// ever fetched the same rows twice.
this.cashierService
.getUnappliedShareAdvances(this.data.clientId)
.getShareCollectionReceipts(this.data.clientId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the member’s receipts.');
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
this.loading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd');
this.receipts = (res.unappliedCollections || []).filter(
(receipt: any) => this.collectionDateOf(receipt) === today
);
// Deliberately nothing ticked, not even when a single receipt is listed. Applying a
// collection turns the member's money into share capital, so which receipt it comes out of
// should be a choice the clerk made rather than one they inherited from a pre-ticked box
// and never looked at.
this.available = [
...(res.own || []).map((row: any) => receiptFromOwn(row)),
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
];
// Nothing pre-selected, not even when a single receipt is listed. Settling turns the
// member's money into share capital, so which receipt it comes out of should be a choice
// somebody made rather than one they inherited and never looked at.
}
this.loading = false;
});
this.cashierService
.getUnattributedShareCollections()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.unattributed = res?.rows || []));
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
// background so opening that popup never waits on a request.
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the GL account list.');
this.loadingGlAccounts = false;
return of(null);
})
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
// 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;
});
@@ -196,100 +126,80 @@ export class ApplyCollectionDialogComponent implements OnInit {
required: Number(res.required || 0)
};
}
this.loadingRequirement = false;
});
}
/** Receipt object currently being viewed in detail popup. */
viewingReceiptDetail: any = null;
// ── The two slots ───────────────────────────────────────────────────────────────────────────
/**
* 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;
});
/** Receipts offered for a slot: everything available except whatever the other slot already holds. */
optionsFor(slot: ReceiptSlot): ReceiptOption[] {
const taken = slot === 'share' ? this.chargeReceipt : this.shareReceipt;
return taken ? this.available.filter((option) => option.key !== taken.key) : this.available;
}
closeReceiptDetail(): void {
this.viewingReceiptDetail = null;
this.viewingReceiptGlEntries = null;
selected(slot: ReceiptSlot): ReceiptOption | null {
return slot === 'share' ? this.shareReceipt : this.chargeReceipt;
}
toggleAll(): void {
if (this.isAllSelected()) {
this.selectedIds = [];
} else {
this.selectedIds = this.receipts.map((r) => r.id);
}
this.syncLookupFieldsToSelection();
isPicked(slot: ReceiptSlot, option: ReceiptOption): boolean {
return this.selected(slot)?.key === option.key;
}
/**
* 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.
* Chooses a receipt for a slot, attaching it to this member first if it is still only a voucher
* number.
*
* 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.
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
* so it has to be attributed before it can settle anything. That attribution is exactly the
* by-voucher lookup a hand-typed voucher number already runs; doing it on the pick means the
* counter makes one deliberate choice against a member they can see, instead of an Attach in one
* table followed by a tick in another.
*/
private syncLookupFieldsToSelection(): void {
if (!this.selectedIds.length) {
this.glAccountNo = '';
this.voucherNo = '';
pick(slot: ReceiptSlot, option: ReceiptOption): void {
if (this.attaching || this.submitting) {
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 || '';
if (this.isPicked(slot, option)) {
this.clear(slot);
return;
}
if (option.collectionId !== null) {
this.assign(slot, option);
return;
}
this.attaching = option.key;
this.errorMsg = '';
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
this.attaching = null;
if (!res) {
return;
}
// Attached now, so it takes its real row's place in the pool -- picking it a second time (or
// in the other slot) must never run the attach again.
const attached = receiptFromOwn(res);
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
this.assign(slot, attached);
});
}
clear(slot: ReceiptSlot): void {
this.assign(slot, null);
}
private assign(slot: ReceiptSlot, option: ReceiptOption | null): void {
if (slot === 'share') {
this.shareReceipt = option;
} else {
this.chargeReceipt = option;
}
}
/**
* 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 still has to be covered ────────────────────────────────────────────────────────────
/**
* 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
* server checks the Sundry-side receipts against in {@link apply}.
* What the share receipt has to reach — the share value alone once the fee has its own receipt,
* otherwise the combined share-plus-fee total, matching exactly what the server checks.
*/
get shareRequired(): number {
if (!this.requirement) {
@@ -298,20 +208,19 @@ export class ApplyCollectionDialogComponent implements OnInit {
return this.chargeReceipt ? this.requirement.shareAmount : this.requirement.required;
}
/** What the share receipts are still missing, zero once fully covered. */
get shortfall(): number {
const short = this.shareRequired - this.selectedTotal;
return short > 0 ? short : 0;
/** What the chosen share receipt is still missing, zero once it covers the requirement. */
get shareShortfall(): number {
const short = this.shareRequired - Number(this.shareReceipt?.amount || 0);
return this.shareReceipt && short > 0 ? short : 0;
}
/** Whether a verified entry fee receipt still needs to be found — false when there is no fee, or
* when the fee was already folded into the share receipt(s) above. */
get chargeUnverified(): boolean {
return !!this.requirement && this.requirement.chargeAmount > 0 && !this.chargeReceipt;
/** Whether this product charges an entry fee at all. */
get hasFee(): boolean {
return !!this.requirement && this.requirement.chargeAmount > 0;
}
/** Whether the found entry fee receipt actually covers the fee. */
get chargeShortfall(): number {
/** What the chosen fee receipt is still missing, zero once it covers the fee. */
get feeShortfall(): number {
if (!this.requirement || !this.chargeReceipt) {
return 0;
}
@@ -320,209 +229,129 @@ export class ApplyCollectionDialogComponent implements OnInit {
}
/**
* A receipt's collection date as {@code yyyy-MM-dd}.
* Whether the settlement can go through.
*
* Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array
* — rather than assuming one, so the filter cannot silently match nothing if the serialiser
* behind this endpoint changes.
* Blocked on a shortfall rather than letting the server reject it: the figures are all on screen
* already, so a submission that can only come back as an error is a round trip nobody needed.
*/
private collectionDateOf(receipt: any): string {
const raw = receipt?.collection_date ?? receipt?.collectionDate;
if (Array.isArray(raw) && raw.length >= 3) {
const [
year,
month,
day
] = raw;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return typeof raw === 'string' ? raw.slice(0, 10) : '';
get canApply(): boolean {
return !!this.shareReceipt && this.shareShortfall === 0 && this.feeShortfall === 0 && !this.submitting;
}
isSelected(id: number): boolean {
return this.selectedIds.includes(id);
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
/** Slot the open search popup is searching for, or null while it is closed. */
searchFor: ReceiptSlot | null = null;
searchVoucherNo = '';
searchGlAccountNo = '';
searching = false;
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
glAccounts: any[] = [];
loadingGlAccounts = true;
openSearch(slot: ReceiptSlot): void {
this.searchFor = slot;
this.searchVoucherNo = '';
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
this.errorMsg = '';
}
toggle(id: number): void {
this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [
...this.selectedIds,
id
];
this.syncLookupFieldsToSelection();
closeSearch(): void {
this.searchFor = null;
this.errorMsg = '';
}
/**
* What the selected receipts come to.
*
* Shown live as receipts are ticked, because the settlement only goes through when the total
* covers the account — checking that by adding up the rows by eye is exactly what leads to a
* rejected submission.
*/
get selectedTotal(): number {
return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
/**
* Resolves the voucher number the member quotes into a receipt and adds it to the list.
*
* The list is deliberately narrow — this member, today, unapplied — so the search is how a
* receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that
* is quicker to type than to find. The lookup itself is not restricted to this member, so a
* 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.
*
* <p>
* 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) {
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
const glAccountNo = this.searchGlAccountNo.trim();
const slot = this.searchFor;
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
// not that none is required.
if (!voucher || !glAccountNo || !slot || this.searching) {
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.searching = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.lookingUp = false;
this.searching = false;
if (!res) {
return;
}
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which
// 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)
const found = receiptFromOwn(res);
this.available = [
found,
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
];
if (!this.isSelected(res.id)) {
this.selectedIds = [
...this.selectedIds,
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;
}
// Found it -- the popup has done its job, so it closes rather than sitting open over a
// receipt that now already shows, ticked, in the table behind it.
this.voucherSearchOpen = false;
this.assign(slot, found);
this.searchFor = null;
});
}
/**
* Same lookup as {@link lookupVoucher}, against its own GL account and voucher fields — for a
* receipt that covers the entry fee rather than the share value.
*
* Deliberately NOT added to {@link receipts}/{@link selectedIds}: this receipt is kept on its own
* ({@link chargeReceipt}) and sent to the server as a distinct field, so it is never swept into the
* Sundry-side total and never gets a settlement GL entry of its own — it proves the fee was paid,
* it does not move money that never sat in Sundry to begin with.
*/
lookupChargeVoucher(): void {
const voucher = this.chargeVoucherNo.trim();
const glAccountNo = this.chargeGlAccountNo.trim();
if (!voucher || !glAccountNo || this.lookingUpCharge) {
return;
}
this.lookingUpCharge = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.lookingUpCharge = false;
if (!res) {
return;
}
this.chargeReceipt = res;
this.chargeVoucherNo = '';
});
}
/** Clears a wrongly-found entry fee receipt so a different voucher can be searched instead. */
clearChargeReceipt(): void {
this.chargeReceipt = null;
}
/**
* Resolves one voucher number into the receipt it names, common to both lookups above. Each caller
* decides what to do with the result — added to the shared table for a share receipt, kept on its
* 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.
// No client-match check: any receipt can settle any share account, regardless of which member it
// was first recorded against.
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
return of(null);
})
);
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: ReceiptOption | null = null;
/**
* The receipt's own real Dr/Cr legs, read under its own {@code gl_transaction_id} — never the GL
* account picked in a search box, which is unrelated screen state naming a different account.
*/
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: ReceiptOption, event: Event): void {
event.stopPropagation();
if (option.collectionId === null) {
return;
}
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(option.collectionId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.viewingGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (this.selectedIds.length === 0 || this.submitting) {
if (!this.canApply || !this.shareReceipt) {
return;
}
this.submitting = true;
this.errorMsg = '';
// The first receipt leads on the URL; the rest ride along in the body, and the server settles
// them as one transaction. The entry fee receipt, when one was found, rides along as its own
// field — never folded into additionalCollectionIds, so the server never sums it into the
// Sundry-side total or posts a GL entry for it.
const [
leadId,
...additionalCollectionIds
] = this.selectedIds;
const data: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds };
// The share receipt leads on the URL and is the only one the server sums into the Sundry-side
// total. The fee receipt rides along as its own field so it is never added to that total and
// never gets a GL entry of its own -- its money was already recognised as income when the
// cashier took it.
const payload: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds: [] };
if (this.chargeReceipt) {
data.chargeCollectionId = this.chargeReceipt.id;
payload.chargeCollectionId = this.chargeReceipt.collectionId;
}
this.cashierService
.applyShareAdvance(leadId, data)
.applyShareAdvance(this.shareReceipt.collectionId as number, payload)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Applying the collection failed.');
@@ -537,6 +366,11 @@ export class ApplyCollectionDialogComponent implements OnInit {
});
}
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
glLabel(option: ReceiptOption): string {
return receiptGlLabel(option, this.glAccounts);
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
@@ -15,6 +15,7 @@ import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
import { ApplySharesComponent } from '../shares-account-actions/apply-shares/apply-shares.component';
/** Custom Services */
import { SharesService } from '../shares.service';
@@ -175,7 +176,6 @@ export class SharesAccountViewComponent implements OnInit {
case 'Close':
case 'Activate':
case 'Undo Approval':
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
@@ -190,6 +190,9 @@ export class SharesAccountViewComponent implements OnInit {
case 'Apply Collection':
this.applyCollection();
break;
case 'Apply Additional Shares':
this.applyAdditionalShares();
break;
case 'Share Certificate':
this.viewShareCertificate();
break;
@@ -206,8 +209,10 @@ export class SharesAccountViewComponent implements OnInit {
*/
private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '820px',
maxWidth: '92vw',
// Two receipt slots and a requirement strip, nothing wider -- the screen used to carry a
// multi-tick table plus a second, separate attach table, which is what needed 820px.
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
shareAccountId: this.sharesAccountData.id,
@@ -219,18 +224,54 @@ export class SharesAccountViewComponent implements OnInit {
if (result) {
// Account data comes from a route resolver, so re-running the route is what refreshes the
// newly revealed transaction and the now-paid charges.
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
this.reloadAccount();
}
});
}
/**
* Raises a request for a further tranche of shares on this account.
*
* A dialog rather than its own page, like Apply Collection beside it: both are a short form filled
* in with the member standing at the counter, and sending the clerk to a separate screen only
* meant losing the account they were looking at to come back to.
*/
private applyAdditionalShares() {
const dialogRef = this.dialog.open(ApplySharesComponent, {
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo,
unitPrice: this.sharesAccountData.currentMarketPrice
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.reloadAccount();
}
});
}
/**
* Re-runs this account's route so its resolver refetches everything on screen.
*
* Through the root first, because Angular reuses the component when only the same route's data
* changes — which would leave the page showing exactly the figures the dialog just changed.
*/
private reloadAccount() {
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
}
/**
* Whether the member's money has actually been taken and the shares issued.
*
+8
View File
@@ -76,6 +76,14 @@ const routes: Routes = [
(m) => m.ShareStatusReportComponent
)
},
{
path: 'reports/product-summary',
data: { title: 'Share Product Summary', breadcrumb: 'Product Summary' },
loadComponent: () =>
import('./reports/share-product-summary/share-product-summary.component').then(
(m) => m.ShareProductSummaryComponent
)
},
{
path: 'reports/nominal-shares',
data: { title: 'Nominal Share Report', breadcrumb: 'Nominal Share Report' },
-2
View File
@@ -30,7 +30,6 @@ import { RejectSharesAccountComponent } from './shares-account-actions/reject-sh
import { CloseSharesAccountComponent } from './shares-account-actions/close-shares-account/close-shares-account.component';
import { ActivateSharesAccountComponent } from './shares-account-actions/activate-shares-account/activate-shares-account.component';
import { UndoApprovalSharesAccountComponent } from './shares-account-actions/undo-approval-shares-account/undo-approval-shares-account.component';
import { ApplySharesComponent } from './shares-account-actions/apply-shares/apply-shares.component';
import { RedeemSharesComponent } from './shares-account-actions/redeem-shares/redeem-shares.component';
import { ApproveSharesComponent } from './shares-account-actions/approve-shares/approve-shares.component';
import { ApproveShareDialogComponent } from './shares-account-actions/approve-shares/approve-share-dialog/approve-share-dialog.component';
@@ -83,7 +82,6 @@ import { MatInputModule } from '@angular/material/input';
CloseSharesAccountComponent,
ActivateSharesAccountComponent,
UndoApprovalSharesAccountComponent,
ApplySharesComponent,
RedeemSharesComponent,
ApproveSharesComponent,
ApproveShareDialogComponent,
+8
View File
@@ -243,6 +243,14 @@ export class SharesService {
return this.http.get('/sharereports/nominal-shares', { params: this.cleanParams(params) });
}
/**
* Every share product's authorised capital against what has actually been paid up — shares issued,
* shares remaining, unpaid allotments and the distinctive-number range, per product.
*/
getShareProductSummary(): Observable<any> {
return this.http.get('/sharereports/product-summary');
}
/** Shareholding pattern — members and capital per holding band, plus the per-product split. */
getShareWiseReport(
params: { asOnDate?: string; officeId?: number | string; productId?: number | string } = {}
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260918',
'hash': 'c7428d0c'
'hash': 'dc7f6680'
},
'allow_switching_backend_instance': true
};
+30 -2
View File
@@ -1962,12 +1962,40 @@ mifosx-client-datatable-step * {
}
/* Apply Collection Dialog Panel Styling */
/* No overflow:hidden here. The dialog's own .dialog-wrapper already clips to the same radius, and
clipping at the surface as well cut off the voucher-search and receipt-detail popups, which the
dialog renders as its own fixed-position children. */
.apply-collection-dialog-panel {
.mat-mdc-dialog-container,
.mdc-dialog__surface {
border-radius: 16px !important;
border-radius: 12px !important;
padding: 0 !important;
overflow: hidden !important;
box-shadow: 0 25px 60px -15px rgb(15 23 42 / 30%) !important;
}
// Fix Material MDC button icon + text alignment inside both dialogs
.mat-mdc-button-base {
// The MDC internal label span — make it flex so icon & text sit on the same axis
.mdc-button__label {
display: inline-flex !important;
align-items: center !important;
gap: 0.3rem !important;
line-height: 1 !important;
}
// mat-icon inside any button in these dialogs
.mat-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
font-size: 18px !important;
line-height: 1 !important;
vertical-align: middle !important;
overflow: visible !important;
flex-shrink: 0;
}
}
}