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:
@@ -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 }} ×
|
||||
{{ '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 ×
|
||||
{{ 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 › 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 › 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];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+6
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
+53
-1
@@ -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>
|
||||
|
||||
+90
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user