feat(shares, cashier): Entry Type tagging on Transaction Entry, real GL details on receipts

Adds an Entry Type field to Transaction Entry's GL-crediting rows (Receipt / Collection),
fetched from the backend's GLEntryType code, so the counter can flag what a GL credit is
actually for (General / Share Collection / DD Issue) instead of leaving every GL account
picker throughout Share Advance Collection guessing from the whole chart of accounts.

Apply Share / Additional Share screens:
- GL Account picker narrowed to accounts that actually have an eligible receipt, and only
  auto-selected when exactly one candidate exists -- no more defaulting to whichever account
  sorts first (Cash at Vault).
- Voucher tables show Description and Collected By, and a Detail view exposes the receipt's
  real Dr/Cr GL legs instead of the GL Account dropdown's current unrelated selection.
- Additional Share funds from a single voucher (radio-style selection); ticking a receipt
  syncs the GL Account picker to its own actual account.
- Checker's Additional Share Request review dialog gets the same Description + real GL entry
  detail, expandable per voucher, so approval is never a guess about what will post.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 17:45:05 +05:30
co-authored by Claude Sonnet 5
parent 41540ad24e
commit e53dfb5a66
11 changed files with 1866 additions and 390 deletions
+16
View File
@@ -401,6 +401,12 @@ export class CashierService {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
* which is unrelated screen state that can name a different account than this receipt sits in. */
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
}
/** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests');
@@ -413,4 +419,14 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable<any> {
return this.http.get(`/shareadditionrequests/${transactionId}`);
}
/**
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
* options should still be usable for every other field.
*/
getEntryTypeOptions(): Observable<any[]> {
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
}
}
@@ -191,6 +191,25 @@
</select>
}
</div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div>
</div>
@@ -1562,6 +1581,25 @@
</select>
}
</div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div>
</div>
<div class="ws-vdivider"></div>
@@ -53,6 +53,19 @@ interface TxnRow {
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
sponsorBankId?: number | null;
/**
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
*
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
* any account to post cash into — deliberately, so a broken product mapping can never block a
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
* account with nothing on the row itself saying which one the money was collected for, and a
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
*/
entryTypeCvId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
@@ -1864,6 +1877,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
this.loadDdGstPercentage();
this.loadSponsorBanks();
this.cashierService
.getEntryTypeOptions()
.pipe(catchError(() => of([])))
.subscribe((options: any[]) => {
this.entryTypeOptions = options ?? [];
});
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -2379,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
}
/**
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
* this exists for stays reliable; an admin extends the list there, not in this file.
*/
entryTypeOptions: { id: number; name: string }[] = [];
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
@@ -2848,7 +2884,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement.
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
// See TxnRow.entryTypeCvId.
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
};
this.saving = true;
@@ -5905,6 +5944,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
@@ -6,112 +6,189 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<div class="container action-container">
<mat-card class="action-card shadow-sm">
<!-- Compact Header -->
<div class="card-header-banner">
<div class="header-icon-box">
<mat-icon>add_card</mat-icon>
</div>
<div class="header-text font-accent">
<h2>{{ 'Apply Additional Shares' | translate }}</h2>
<p>Purchase additional share tranches via cash receipts or savings debit</p>
</div>
</div>
<form [formGroup]="applySharesForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="requestedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
one the account was opened under, so it is captured per purchase. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input matInput type="number" min="1" required formControlName="requestedShares" />
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" />
</mat-form-field>
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
value the clerk read back to the member. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput formControlName="amount" />
<mat-hint
>{{ 'labels.inputs.Total No. of Shares' | translate }} &times;
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
>
</mat-form-field>
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
moves it out to share capital. -->
<div class="payment-mode">
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
</mat-radio-group>
<mat-card-content class="card-body">
<!-- SECTION 1: Application & Resolution Details -->
<div class="form-section">
<div class="section-title">
<mat-icon class="sec-icon">assignment</mat-icon>
<span>Application & Resolution Details</span>
</div>
@if (paymentMode === 'CASH') {
<div class="funding-panel">
<p class="funding-intro">
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in
Share Sundry against them, and approving this request moves it into share capital.
</p>
<div class="form-grid form-grid-3">
<!-- Request Date -->
<mat-form-field appearance="outline" density="compact" (click)="requestedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="gl-select-field">
<!-- Resolution Number -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" placeholder="e.g. RES-2026-08" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Resolution Date -->
<mat-form-field appearance="outline" density="compact" (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- SECTION 2: Share Purchase Calculation -->
<div class="form-section financial-section">
<div class="section-title">
<mat-icon class="sec-icon">calculate</mat-icon>
<span>Share Purchase Calculation</span>
</div>
<div class="form-grid form-grid-3 align-items-center">
<!-- Total No. of Shares -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input
matInput
type="number"
min="1"
required
formControlName="requestedShares"
placeholder="e.g. 100"
class="shares-count-input"
/>
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Current Price -->
<mat-form-field appearance="outline" density="compact" class="price-field">
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" class="price-input" />
<span matSuffix class="currency-label">per share</span>
</mat-form-field>
<!-- Computed Amount Card -->
<div class="amount-display-card">
<span class="amount-label">{{ 'labels.inputs.Amount' | translate }}</span>
<span class="amount-value">{{ amount | number: '1.2-2' }}</span>
<span class="amount-subtext"
>{{ applySharesForm.get('requestedShares').value || 0 }} shares &times;
{{ sharesAccountData?.currentMarketPrice || 0 }}</span
>
</div>
</div>
</div>
<!-- SECTION 3: Payment / Funding Option -->
<div class="form-section">
<div class="section-title">
<mat-icon class="sec-icon">account_balance_wallet</mat-icon>
<span>{{ 'labels.inputs.Payment Option' | translate }}</span>
</div>
<div class="payment-card-group">
<div class="payment-card-option" [class.active]="paymentMode === 'CASH'" (click)="setPaymentMode('CASH')">
<div class="option-icon-box">
<mat-icon>payments</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Apply Collection (Cash)</span>
<span class="option-desc">Link receipts paid at counter sitting in Share Sundry</span>
</div>
@if (paymentMode === 'CASH') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
<div
class="payment-card-option"
[class.active]="paymentMode === 'SAVINGS'"
(click)="setPaymentMode('SAVINGS')"
>
<div class="option-icon-box">
<mat-icon>account_balance</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Debit from Savings</span>
<span class="option-desc">Direct debit from member's linked savings account</span>
</div>
@if (paymentMode === 'SAVINGS') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
</div>
</div>
<!-- CASH FUNDING DETAILS -->
@if (paymentMode === 'CASH') {
<div class="funding-panel cash-panel animate-fade-in">
<div class="funding-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
Select the one receipt that covers this payment, or enter a voucher number to search. Click
<mat-icon class="inline-icon">visibility</mat-icon> on any row to view full details popup.
</span>
</div>
<!-- Toolbar: GL Account Select & Voucher Lookup -->
<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="gl-select"
class="custom-select"
[(ngModel)]="glAccountNo"
[ngModelOptions]="{ standalone: true }"
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
@@ -125,173 +202,394 @@
</select>
</div>
<div class="voucher-lookup">
<input
#voucherInput
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
{{ lookingUpVoucher ? 'Finding…' : 'Find' }}
</button>
</div>
@if (voucherError) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
<div class="voucher-lookup-box">
<label class="toolbar-label">
<mat-icon class="mini-icon">search</mat-icon> Find Specific Voucher
</label>
<div class="lookup-input-group">
<input
#voucherInput
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
@if (lookingUpVoucher) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div>
}
</div>
</div>
@if (voucherError) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
}
<!-- Vouchers Array List / Table -->
<div class="vouchers-array-container">
@if (loadingVouchers) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading the member's receipts…</span>
<span>Loading member receipts…</span>
</div>
} @else if (vouchers.length === 0) {
<div class="funding-empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<div class="funding-empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry
GL. Once it is approved, the voucher appears here.
</div>
<mat-icon class="empty-icon">receipt_long</mat-icon>
<div class="empty-text">
<strong>No unapplied receipts found for this member.</strong>
<p>
Collect cash at <em>Cashier &rsaquo; Transaction Entry</em> as a deposit to Share Sundry GL. Once
posted, it will appear here.
</p>
</div>
</div>
} @else {
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one request, and they fund it together. -->
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.id) {
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)">
<td class="ta-center">
<input
type="checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td>
<td class="ta-left">{{ v.collection_date }}</td>
<td class="ta-right">
<b>{{ v.amount | number: '1.2-2' }}</b>
</td>
</tr>
}
</tbody>
@if (selectedVoucherIds.length > 0) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }}
</td>
<td class="ta-right">
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b>
</td>
</tr>
</tfoot>
}
</table>
<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>Vouchers / Receipts</span>
<span class="badge-count">{{ vouchers.length }}</span>
</div>
</div>
<!-- Compact Table View -->
<div class="table-responsive">
<table class="receipt-table">
<thead>
<tr>
<th class="col-checkbox ta-center">#</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>
<!-- One receipt funds one tranche — no combining instalments here. -->
@for (v of vouchers; track v.id) {
<tr
class="receipt-row"
[class.selected]="isVoucherSelected(v.id)"
(click)="toggleVoucher(v.id)"
>
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<input
type="radio"
name="voucherSelect"
class="custom-checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
/>
</td>
<td class="ta-left">
<span class="voucher-badge font-mono">{{ v.voucher_no || v.receipt_no }}</span>
</td>
<td class="ta-left date-cell">{{ v.collection_date }}</td>
<td class="ta-left desc-cell" [title]="v.note">{{ v.note || 'Share Collection' }}</td>
<td class="ta-left user-cell">
<span class="user-chip">
<mat-icon class="chip-icon">person_outline</mat-icon>
{{ v.collected_by_name || 'System' }}
</span>
</td>
<td class="ta-right amount-cell">
<span class="val font-semibold">{{ v.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)="openVoucherDetail(v, $event)"
>
<mat-icon class="eye-icon">visibility</mat-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Summary Footer Bar -->
<div class="vouchers-footer">
<div class="selected-summary">
<span class="summary-text font-accent">
<strong>{{ selectedVoucherIds.length }}</strong> of
<strong>{{ vouchers.length }}</strong> receipts selected
</span>
<div class="summary-amounts">
<span class="total-label">Selected Total:</span>
<span class="total-value font-semibold">{{ selectedVoucherTotal | number: '1.2-2' }}</span>
<span class="target-divider">/</span>
<span class="target-value">Target {{ amount | number: '1.2-2' }}</span>
</div>
</div>
<!-- Coverage Status Indicator Pill -->
<div class="coverage-status">
@if (selectedVoucherIds.length === 0) {
<span class="status-badge badge-neutral">
<mat-icon>info</mat-icon> Tick at least 1 receipt
</span>
} @else if (voucherShortfall > 0) {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> Shortfall {{ voucherShortfall | number: '1.2-2' }}
</span>
} @else {
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Funded </span>
}
</div>
</div>
</div>
<!-- Says how much is still missing rather than only that it is short, so the clerk
can tell the member what to pay without working it out. -->
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
<div class="alert-error">
<div class="alert-box alert-warning mt-8">
<mat-icon>error_outline</mat-icon>
<span>
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first.
Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is
<strong>{{ voucherShortfall | number: '1.2-2' }} short</strong> of tranche target
{{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
</span>
</div>
}
}
</div>
}
</div>
}
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel">
<p class="funding-intro">
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so
their balance drops now. Approving the request then moves it into share capital.
</p>
<!-- SAVINGS FUNDING DETAILS -->
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel savings-panel animate-fade-in">
<div class="funding-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
The tranche amount will be automatically debited from the member's linked savings account into Share
Sundry upon submission.
</span>
</div>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span>
</div>
} @else if (savingsEligibility) {
<div class="funding-summary">
@if (savingsEligibility.savingsAccountNo) {
<div class="funding-row">
<span>Savings account</span>
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
<div class="funding-row">
<span>Available balance</span>
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="funding-row funding-row--total">
<span>To debit</span>
<span>{{ amount | number: '1.2-2' }}</span>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking linked savings account eligibility…</span>
</div>
} @else if (savingsEligibility) {
<div class="savings-card">
@if (savingsEligibility.savingsAccountNo) {
<div class="savings-row">
<span class="lbl">Savings Account No.</span>
<span class="val font-mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
<div class="savings-row">
<span class="lbl">Available Balance</span>
<span class="val font-semibold">{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="savings-row savings-row-total">
<span class="lbl">Amount to Debit</span>
<span class="val font-semibold text-primary">{{ amount | number: '1.2-2' }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-box alert-error mt-8">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
}
</div>
}
}
</div>
}
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
@if (errorMsg) {
<div class="alert-box alert-error mt-12">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a
manager still has to approve the request. -->
<p class="pending-hint">
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry,
only once it is approved — until then it appears under pending, not in the account's transactions.
</p>
<!-- PENDING APPROVAL NOTICE -->
<div class="pending-notice mt-10">
<mat-icon class="notice-icon">schedule</mat-icon>
<span>
Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon
manager approval.
</span>
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
<!-- CARD ACTIONS -->
<mat-card-actions class="card-actions-bar">
<button type="button" mat-stroked-button class="action-btn cancel-btn" [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
<button mat-flat-button color="primary" class="action-btn submit-btn" [disabled]="!canSubmit">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>send</mat-icon>
<span>{{ 'labels.buttons.Submit' | translate }}</span>
}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
<!-- VOUCHER FULL DETAILS POPUP MODAL -->
@if (viewingVoucherDetail) {
<div class="modal-backdrop animate-fade-in" (click)="closeVoucherDetail()">
<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>Voucher Details</span>
<span class="modal-badge font-mono">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no
}}</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeVoucherDetail()">
<mat-icon>close</mat-icon>
</button>
</div>
<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">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Receipt Reference</span>
<span class="detail-val font-mono">{{ viewingVoucherDetail.receipt_no || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client Name</span>
<span class="detail-val">{{
viewingVoucherDetail.client_name || sharesAccountData?.clientName || 'Member'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client ID</span>
<span class="detail-val font-mono">{{
viewingVoucherDetail.client_id || sharesAccountData?.clientId || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collection Date</span>
<span class="detail-val">{{ viewingVoucherDetail.collection_date || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Status</span>
<span class="status-pill status-unapplied">{{ viewingVoucherDetail.status || 'UNAPPLIED' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collected By</span>
<span class="detail-val">{{ viewingVoucherDetail.collected_by_name || 'System Administrator' }}</span>
</div>
<div class="detail-item full-width highlight-amount-box">
<span class="detail-label">Receipt Amount</span>
<span class="amount-val font-mono">{{ viewingVoucherDetail.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">
{{ viewingVoucherDetail.note || 'Share collection deposit to Share Sundry GL' }}
</div>
</div>
<!-- The receipt's own actual GL posting — never the GL Account dropdown above the
voucher search box, which is unrelated screen state and can name a completely
different account than the one this specific receipt sits in. -->
<div class="detail-item full-width">
<span class="detail-label">GL Entry (this receipt's actual posting)</span>
@if (loadingGlEntries) {
<div class="gl-entries-loading">Loading…</div>
} @else if (voucherGlEntries.length === 0) {
<div class="gl-entries-empty">No GL posting recorded for this receipt.</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 (leg of voucherGlEntries; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left font-mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="drcr-pill"
[class.drcr-pill--dr]="leg.drCr === 'Dr'"
[class.drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right font-mono">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
mat-flat-button
[color]="isVoucherSelected(viewingVoucherDetail.id) ? 'warn' : 'primary'"
class="modal-action-btn"
(click)="toggleVoucher(viewingVoucherDetail.id); closeVoucherDetail()"
>
<mat-icon>{{
isVoucherSelected(viewingVoucherDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
}}</mat-icon>
<span>{{ isVoucherSelected(viewingVoucherDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeVoucherDetail()">Close</button>
</div>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit {
glAccounts: any[] = [];
loadingGlAccounts = true;
/** 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;
/** What the member's savings can cover, for the savings route. */
savingsEligibility: {
eligible: boolean;
@@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit {
.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);
}
openVoucherDetail(voucher: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
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.
*
@@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit {
return this.selectedVoucherIds.includes(id);
}
/**
* 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 {
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
...this.selectedVoucherIds,
id
];
const wasSelected = this.isVoucherSelected(id);
this.selectedVoucherIds = wasSelected ? [] : [id];
this.voucherError = '';
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
// that plainly does have one is already chosen.
if (!wasSelected) {
const voucher = this.vouchers.find((v) => v.id === id);
if (voucher?.gl_code) {
this.glAccountNo = voucher.gl_code;
}
}
}
/**
@@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit {
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Added to the selection rather than replacing it — a looked-up voucher is usually the
// second half of a part-payment, not a change of mind about the first. No amount check
// here: one instalment is not meant to cover the tranche on its own.
// 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)
];
if (!this.isVoucherSelected(res.id)) {
this.selectedVoucherIds = [
...this.selectedVoucherIds,
res.id
];
}
this.selectedVoucherIds = [res.id];
});
}
@@ -168,6 +168,8 @@
<th class="col-tick"></th>
<th class="ta-left">Voucher / Receipt</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>
</tr>
</thead>
@@ -186,6 +188,8 @@
</td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -197,7 +201,7 @@
<tfoot>
<tr class="total-row">
<td class="col-tick"></td>
<td class="ta-left" colspan="2">
<td class="ta-left" colspan="4">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
@@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit {
)
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false;
});
@@ -68,18 +68,70 @@
<tr>
<th class="ta-left">Voucher / Receipt</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="ta-center">Detail</th>
</tr>
</thead>
<tbody>
@for (v of detail.vouchers; track v.id) {
<tr>
<tr class="sard-voucher-row" (click)="toggleVoucherGlEntries(v.id)">
<td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td>
<td class="ta-left">{{ v.collectionDate }}</td>
<td class="ta-left sard-desc-cell" [title]="v.note">{{ v.note || '—' }}</td>
<td class="ta-left">{{ v.collectedByName || '—' }}</td>
<td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td>
<td class="ta-center">
<button
type="button"
mat-icon-button
class="sard-detail-btn"
(click)="$event.stopPropagation(); toggleVoucherGlEntries(v.id)"
title="View this receipt's actual GL posting"
>
<mat-icon>visibility</mat-icon>
</button>
</td>
</tr>
@if (expandedVoucherId === v.id) {
<tr class="sard-gl-row">
<td colspan="6">
@if (loadingGlEntriesId === v.id) {
<div class="sard-gl-loading">Loading GL entry…</div>
} @else if (!glEntriesByVoucherId[v.id]?.length) {
<div class="sard-gl-empty">No GL posting recorded for this receipt.</div>
} @else {
<table class="sard-gl-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 (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="sard-drcr-pill"
[class.sard-drcr-pill--dr]="leg.drCr === 'Dr'"
[class.sard-drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right amount-cell">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</td>
</tr>
}
}
</tbody>
</table>
@@ -121,3 +121,93 @@ $danger: #b3261e;
font-size: 13px;
margin-top: 12px;
}
.ta-center {
text-align: center;
}
.sard-voucher-row {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
}
.sard-desc-cell {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sard-detail-btn {
width: 30px;
height: 30px;
line-height: 30px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $primary;
}
}
.sard-gl-row td {
background: #fbfcfe;
border-top: none;
padding: 4px 8px 10px;
}
.sard-gl-loading,
.sard-gl-empty {
font-size: 12px;
color: $muted;
padding: 4px 0;
}
.sard-gl-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid $border;
border-radius: 6px;
overflow: hidden;
th {
color: $muted;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 5px 8px;
background: #f4f7fb;
border-bottom: 1px solid $border;
}
td {
padding: 5px 8px;
border-top: 1px solid #eceff4;
}
}
.sard-drcr-pill {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
&.sard-drcr-pill--dr {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.sard-drcr-pill--cr {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
}
@@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
actioning = false;
detail: any = null;
/** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */
expandedVoucherId: number | null = null;
loadingGlEntriesId: number | null = null;
/** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */
glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {};
public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>);
public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA);
@@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
return this.detail?.vouchers?.length || 0;
}
/** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */
toggleVoucherGlEntries(voucherId: number): void {
if (this.expandedVoucherId === voucherId) {
this.expandedVoucherId = null;
return;
}
this.expandedVoucherId = voucherId;
if (this.glEntriesByVoucherId[voucherId]) {
return;
}
this.loadingGlEntriesId = voucherId;
this.cashierService
.getShareAdvanceGlEntries(voucherId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glEntriesByVoucherId[voucherId] = res?.entries || [];
this.loadingGlEntriesId = null;
});
}
approve(): void {
this.act('approveadditionalshares');
}