A
This commit is contained in:
@@ -6,292 +6,367 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<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>
|
||||
<div class="dialog-wrapper">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>trending_up</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Apply Additional Shares</h2>
|
||||
<p>
|
||||
Share account <strong class="mono">{{ data.accountNo }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- ── Shares / Price / Amount strip — outside scroll area so always visible ── -->
|
||||
<div class="shares-subheader">
|
||||
<div class="shares-strip">
|
||||
<div class="shares-cell shares-cell-input">
|
||||
<span class="shares-label">Shares</span>
|
||||
<input
|
||||
class="req-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
[(ngModel)]="requestedShares"
|
||||
(ngModelChange)="onSharesChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="shares-cell">
|
||||
<span class="shares-label">Unit Price</span>
|
||||
<span class="shares-value mono">{{ unitPrice | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="shares-cell shares-cell-total">
|
||||
<span class="shares-label">Total Amount</span>
|
||||
<span class="shares-value mono">{{ amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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="gl-select-field">
|
||||
<select
|
||||
class="gl-select"
|
||||
[(ngModel)]="glAccountNo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
|
||||
</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="voucher-lookup">
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (loadingVouchers) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading the member's 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>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<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.
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!savingsEligibility.eligible) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ savingsEligibility.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-error">
|
||||
<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>
|
||||
<mat-dialog-content class="dialog-body">
|
||||
<!-- ── Step 1: Board Resolution ──────────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">1</span>
|
||||
<div class="slot-title">
|
||||
<h3>Board Resolution</h3>
|
||||
<p>The meeting that authorised this tranche, not the one the account was opened under.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</header>
|
||||
|
||||
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
|
||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Resolution No.</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" placeholder="e.g. BR/2026/14" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Meeting Date</span>
|
||||
<input type="date" [(ngModel)]="resolutionDate" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Step 2: Funding ───────────────────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">2</span>
|
||||
<div class="slot-title">
|
||||
<h3>Funding</h3>
|
||||
<p>Approval posts Dr Sundry / Cr Share Capital either way.</p>
|
||||
</div>
|
||||
@if (funding === 'CASH') {
|
||||
<button type="button" class="slot-search" (click)="openSearch()">
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find by voucher</span>
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="funding-choice">
|
||||
<button
|
||||
type="button"
|
||||
class="funding-btn"
|
||||
[class.funding-on]="funding === 'CASH'"
|
||||
(click)="chooseFunding('CASH')"
|
||||
>
|
||||
<mat-icon>payments</mat-icon>
|
||||
<span class="funding-name">Cash</span>
|
||||
<span class="funding-hint">Already paid at the counter</span>
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
|
||||
<button
|
||||
type="button"
|
||||
class="funding-btn"
|
||||
[class.funding-on]="funding === 'SAVINGS'"
|
||||
(click)="chooseFunding('SAVINGS')"
|
||||
>
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span class="funding-name">Savings</span>
|
||||
<span class="funding-hint">Debited on submit</span>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</form>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@if (funding === 'CASH') {
|
||||
@if (loadingReceipts) {
|
||||
<div class="state-block">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading receipts…</span>
|
||||
</div>
|
||||
} @else if (receipt) {
|
||||
<div class="chosen" [class.chosen-short]="shortfall > 0">
|
||||
<div class="chosen-badge">
|
||||
<mat-icon>{{ shortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
|
||||
</div>
|
||||
<div class="chosen-main">
|
||||
<span class="chosen-voucher mono">{{ receipt.voucherNo }}</span>
|
||||
<span class="chosen-gl">{{ glLabel(receipt) }}</span>
|
||||
</div>
|
||||
<span class="chosen-amount mono">{{ receipt.amount | number: '1.2-2' }}</span>
|
||||
<button type="button" class="chosen-clear" (click)="clearReceipt()" aria-label="Change receipt">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (shortfall > 0) {
|
||||
<p class="slot-note note-bad">
|
||||
Short by {{ shortfall | number: '1.2-2' }} — this receipt does not cover {{ amount | number: '1.2-2' }}.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="slot-note note-good">Covers the {{ amount | number: '1.2-2' }} tranche.</p>
|
||||
}
|
||||
} @else if (available.length) {
|
||||
<ul class="options">
|
||||
@for (option of available; track option.key) {
|
||||
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick(option)">
|
||||
<span class="option-radio"></span>
|
||||
<div class="option-main">
|
||||
<span class="option-voucher mono">{{ option.voucherNo }}</span>
|
||||
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
|
||||
@if (option.description) {
|
||||
<span class="option-desc">{{ option.description }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
|
||||
@if (attaching === option.key) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else if (option.collectionId !== null) {
|
||||
<button
|
||||
type="button"
|
||||
class="option-info"
|
||||
(click)="openDetail(option, $event)"
|
||||
aria-label="GL entries"
|
||||
>
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="slot-empty">
|
||||
No Share Collection receipt is waiting. Collect the cash at <em>Cashier › Transaction Entry</em> with entry
|
||||
type <strong>Share Collection</strong>, or find an earlier voucher by number.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (funding === 'SAVINGS') {
|
||||
@if (checkingSavings) {
|
||||
<div class="state-block">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Checking the linked savings account…</span>
|
||||
</div>
|
||||
} @else if (savings) {
|
||||
<div class="chosen" [class.chosen-short]="!savings.eligible">
|
||||
<div class="chosen-badge">
|
||||
<mat-icon>{{ savings.eligible ? 'check_circle' : 'warning' }}</mat-icon>
|
||||
</div>
|
||||
<div class="chosen-main">
|
||||
<span class="chosen-voucher">
|
||||
@if (savings.savingsAccountNo) {
|
||||
Savings <span class="mono">{{ savings.savingsAccountNo }}</span>
|
||||
} @else {
|
||||
Linked savings account
|
||||
}
|
||||
</span>
|
||||
<span class="chosen-gl">
|
||||
{{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }}
|
||||
</span>
|
||||
</div>
|
||||
@if (savings.savingsBalance !== null) {
|
||||
<span class="chosen-amount mono">{{ savings.savingsBalance | number: '1.2-2' }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="slot-note note-muted">Enter the number of shares to check the member's balance.</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dialog-actions">
|
||||
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||
@if (submitting) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Submitting…</span>
|
||||
} @else {
|
||||
<mat-icon>check</mat-icon>
|
||||
<span>Submit Request</span>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
|
||||
@if (searchOpen) {
|
||||
<div class="overlay" (click)="closeSearch()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>Find a receipt</h3>
|
||||
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<label class="field">
|
||||
<span>GL Account</span>
|
||||
<select [(ngModel)]="searchGlAccountNo" [disabled]="loadingGlAccounts">
|
||||
<option value="">{{ loadingGlAccounts ? 'Loading…' : 'Select the account it was paid into' }}</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Voucher No.</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchVoucherNo"
|
||||
placeholder="CR/18092026/0004"
|
||||
(keyup.enter)="runSearch()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!searchVoucherNo.trim() || !searchGlAccountNo.trim() || searching"
|
||||
(click)="runSearch()"
|
||||
>
|
||||
@if (searching) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<span>Find & Use</span>
|
||||
}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
|
||||
@if (viewing) {
|
||||
<div class="overlay" (click)="closeDetail()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>
|
||||
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
|
||||
</h3>
|
||||
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>Date</dt>
|
||||
<dd class="mono">{{ viewing.date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Amount</dt>
|
||||
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>GL Account</dt>
|
||||
<dd>{{ glLabel(viewing) }}</dd>
|
||||
</div>
|
||||
@if (viewing.cashierName) {
|
||||
<div>
|
||||
<dt>Collected By</dt>
|
||||
<dd>{{ viewing.cashierName }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (viewing.description) {
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ viewing.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<h4 class="detail-heading">Journal Entries</h4>
|
||||
@if (loadingGlEntries) {
|
||||
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
|
||||
} @else if (viewingGlEntries?.length) {
|
||||
<table class="gl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of viewingGlEntries; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ entry.glCode }}</span> {{ entry.accountName }}
|
||||
</td>
|
||||
<td class="num mono">{{ entry.drCr === 'Dr' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="num mono">{{ entry.drCr === 'Cr' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -6,231 +6,195 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #1a3a6b;
|
||||
$border: #d8e0ec;
|
||||
$muted: #6b7a90;
|
||||
$danger: #b3261e;
|
||||
// Dialog chrome, receipt list, chosen state and popups come from the shared
|
||||
// partial. Only what is unique to a tranche request lives below.
|
||||
@use '../../share-dialog.shared' as *;
|
||||
|
||||
.container {
|
||||
max-width: 37rem;
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-mode {
|
||||
margin: 4px 0 16px;
|
||||
/* ── Shares sub-header (sits between dialog-header and dialog-body, never scrolls) ── */
|
||||
|
||||
.shares-subheader {
|
||||
display: flex;
|
||||
flex: none; // same as header/footer — doesn't participate in body flex growth
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.payment-mode-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: $muted;
|
||||
margin-bottom: 8px;
|
||||
/* ── Shares / Price / Amount strip ─────────────────────────────────────── */
|
||||
|
||||
.shares-strip {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border: 1px solid $accent-border;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: $bg-white;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 7%);
|
||||
}
|
||||
|
||||
.payment-mode-options {
|
||||
.shares-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: $bg-white;
|
||||
border-right: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.shares-cell-input {
|
||||
// editable cell — slight highlight
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
&.shares-cell-total {
|
||||
background: $accent-soft;
|
||||
|
||||
.shares-label {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.shares-value {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The funding route's own detail, boxed off from the share fields above it: which voucher, or
|
||||
which savings account, is what the clerk checks before submitting. */
|
||||
.funding-panel {
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
background: #fbfcfe;
|
||||
.shares-label {
|
||||
display: block;
|
||||
font-size: 0.63rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.funding-intro {
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
margin: 0 0 12px;
|
||||
.shares-value {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gl-select-field {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
/* ── Inline number input inside the strip ──────────────────────────────── */
|
||||
|
||||
.gl-select {
|
||||
.req-input {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f4f7fb;
|
||||
color: $muted;
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Remove browser number arrows
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.voucher-lookup {
|
||||
/* ── Board resolution fields ────────────────────────────────────────────── */
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.85rem;
|
||||
|
||||
@media (width <= 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cash / Savings funding selector ───────────────────────────────────── */
|
||||
|
||||
.funding-choice {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.funding-btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.voucher-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.4px;
|
||||
color: $primary;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f4f7fb;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.funding-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.funding-summary {
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.funding-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
color: $primary;
|
||||
|
||||
> span:first-child {
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.funding-row--total {
|
||||
border-top: 1px solid #eceff4;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.funding-empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: #f4f7fb;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.funding-empty-hint {
|
||||
color: $muted;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
|
||||
th {
|
||||
background: #eef2f8;
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eceff4;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #e7effb;
|
||||
}
|
||||
}
|
||||
|
||||
/* The running total against the tranche amount — what decides whether the request is accepted,
|
||||
and not something to be added up by eye once a second receipt is in play. */
|
||||
tfoot .total-row {
|
||||
border-top: 2px solid #cbd5e1;
|
||||
|
||||
td {
|
||||
padding-top: 10px;
|
||||
color: #1f3d7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ta-left {
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 10px;
|
||||
background: $bg-white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
mat-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 22px;
|
||||
color: $text-muted;
|
||||
transition: color 0.15s;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
background: $accent-soft;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 12%);
|
||||
transform: translateY(-1px);
|
||||
|
||||
mat-icon {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.funding-on {
|
||||
border-color: $accent;
|
||||
background: linear-gradient(135deg, $accent-soft 0%, #e0eaff 100%);
|
||||
box-shadow: 0 2px 10px rgb(37 99 235 / 18%);
|
||||
|
||||
mat-icon,
|
||||
.funding-name {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ta-right {
|
||||
text-align: right;
|
||||
.funding-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ta-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fdecea;
|
||||
color: $danger;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pending-hint {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
margin: 0;
|
||||
.funding-hint {
|
||||
font-size: 0.7rem;
|
||||
color: $text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -6,109 +6,116 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { SharesService } from 'app/shares/shares.service';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
|
||||
|
||||
/** Where the money for this tranche comes from. */
|
||||
export type FundingSource = 'CASH' | 'SAVINGS';
|
||||
|
||||
/**
|
||||
* Apply Additional Shares.
|
||||
*
|
||||
* A member buying a further tranche of shares, which — like the account's first application — is
|
||||
* only a request until it is approved. Two things have to be captured that the bare share count
|
||||
* does not carry:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
|
||||
* own number, not the one the account was opened under;</li>
|
||||
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
|
||||
* </ul>
|
||||
* A member buying a further tranche, which — like the account's first application — is only a
|
||||
* request until it is approved. Two things have to be captured that the bare share count does not
|
||||
* carry: the board resolution authorising this particular tranche (its own meeting, its own number,
|
||||
* not the one the account was opened under), and where the money is coming from, because a tranche
|
||||
* nobody has paid for cannot be approved.
|
||||
*
|
||||
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
|
||||
* Dr Sundry / Cr Share Capital pair either way:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
|
||||
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
|
||||
* More than one can be ticked, because a member often pays in instalments and no single
|
||||
* receipt covers the tranche. They are checked here rather than at approval, while the member
|
||||
* is still at the counter to make up any shortfall.</li>
|
||||
* <li><b>Cash</b> — the member already paid at the counter and the cashier's receipt put the money
|
||||
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
|
||||
* funds one tranche; it is checked here rather than at approval, while the member is still at
|
||||
* the counter to make up any shortfall.</li>
|
||||
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
|
||||
* drops immediately. Approval then moves it out to share capital.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
|
||||
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
|
||||
* person, and two different designs for that were only ever two things to learn.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-apply-shares',
|
||||
templateUrl: './apply-shares.component.html',
|
||||
styleUrls: ['./apply-shares.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatRadioModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatIconModule
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class ApplySharesComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private sharesService = inject(SharesService);
|
||||
private cashierService = inject(CashierService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/** Shares account data. */
|
||||
sharesAccountData: any;
|
||||
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
|
||||
public data = inject<{
|
||||
shareAccountId: number;
|
||||
clientId: number;
|
||||
accountNo: string;
|
||||
unitPrice: number;
|
||||
}>(MAT_DIALOG_DATA);
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
maxDate = new Date();
|
||||
/** Apply Share Account form. */
|
||||
applySharesForm: UntypedFormGroup;
|
||||
/** Shares Account Id */
|
||||
accountId: any;
|
||||
|
||||
/** Submission in flight, so the form cannot be posted twice. */
|
||||
submitting = false;
|
||||
/** Anything that stopped the submission, shown above the buttons. */
|
||||
errorMsg = '';
|
||||
|
||||
/**
|
||||
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
|
||||
*
|
||||
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
|
||||
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
|
||||
* rather than sending the member back to the counter for a single combined receipt.
|
||||
*/
|
||||
vouchers: any[] = [];
|
||||
selectedVoucherIds: number[] = [];
|
||||
loadingVouchers = false;
|
||||
lookingUpVoucher = false;
|
||||
voucherError = '';
|
||||
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
|
||||
* from a share product's accounting setup, so a broken or missing product mapping can never
|
||||
* make a genuine voucher unreachable. */
|
||||
glAccountNo = '';
|
||||
glAccounts: any[] = [];
|
||||
loadingGlAccounts = true;
|
||||
/** How many shares this tranche is for. */
|
||||
requestedShares: number | null = null;
|
||||
|
||||
/** The product's current market price. Read-only: the tranche's value and its GL posting must
|
||||
* never be able to disagree because somebody typed over the price. */
|
||||
unitPrice = 0;
|
||||
|
||||
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
|
||||
requestedDate = '';
|
||||
resolutionDate = '';
|
||||
resolutionNumber = '';
|
||||
|
||||
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
||||
get amount(): number {
|
||||
return (Number(this.requestedShares) || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
funding: FundingSource | null = null;
|
||||
|
||||
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
|
||||
available: ReceiptOption[] = [];
|
||||
loadingReceipts = false;
|
||||
|
||||
/** The one receipt funding this tranche. One receipt, one tranche. */
|
||||
receipt: ReceiptOption | null = null;
|
||||
|
||||
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
|
||||
attaching: string | null = null;
|
||||
|
||||
/** What the member's savings can cover, for the savings route. */
|
||||
savingsEligibility: {
|
||||
savings: {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
savingsAccountNo: string | null;
|
||||
@@ -116,19 +123,24 @@ export class ApplySharesComponent implements OnInit {
|
||||
} | null = null;
|
||||
checkingSavings = false;
|
||||
|
||||
constructor() {
|
||||
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
|
||||
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
|
||||
this.sharesAccountData = data.shareAccountActionData;
|
||||
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
|
||||
* "1000" — which asks four times and lets the answers land out of order. */
|
||||
private sharesChanged = new Subject<number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.unitPrice = Number(this.data.unitPrice) || 0;
|
||||
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
|
||||
// asking a clerk to type that in is asking them to retype what the system already knows.
|
||||
this.requestedDate = this.isoOf(this.settingsService.businessDate);
|
||||
|
||||
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
|
||||
if (this.funding === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createApplySharesAccountForm();
|
||||
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
|
||||
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
|
||||
// background so opening that popup never waits on a request.
|
||||
this.cashierService
|
||||
.getShareAdvanceGlAccounts()
|
||||
.pipe(catchError(() => of(null)))
|
||||
@@ -138,191 +150,109 @@ export class ApplySharesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the apply shares form.
|
||||
*
|
||||
* The request date opens on the business date rather than blank: an additional-share request is
|
||||
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
|
||||
* retype what the system already knows.
|
||||
*/
|
||||
createApplySharesAccountForm() {
|
||||
this.applySharesForm = this.formBuilder.group({
|
||||
requestedDate: [
|
||||
this.settingsService.businessDate,
|
||||
Validators.required
|
||||
],
|
||||
requestedShares: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
unitPrice: [{ value: '', disabled: true }],
|
||||
// Disabled, not readonly: the amount is derived from shares x price and must never be a
|
||||
// figure somebody typed, or the tranche's value and its GL posting could disagree.
|
||||
amount: [{ value: '', disabled: true }],
|
||||
resolutionNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
resolutionDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
paymentMode: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
// Recompute as the share count is typed, so the clerk can read the amount back to the member
|
||||
// before committing to a funding route.
|
||||
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
// A savings verdict is cleared the moment the amount changes rather than when the re-check
|
||||
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
|
||||
// vouchers stay: they are the receipts the member actually holds, and the running total below
|
||||
// shows against the new amount whether they still cover it.
|
||||
this.voucherError = '';
|
||||
this.savingsEligibility = null;
|
||||
});
|
||||
|
||||
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
|
||||
// on every keystroke of "1000" asks four times and lets the answers land out of order.
|
||||
this.applySharesForm
|
||||
.get('requestedShares')
|
||||
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
}
|
||||
});
|
||||
|
||||
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
|
||||
this.vouchers = [];
|
||||
this.selectedVoucherIds = [];
|
||||
this.voucherError = '';
|
||||
this.errorMsg = '';
|
||||
this.savingsEligibility = null;
|
||||
if (mode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
} else if (mode === 'CASH') {
|
||||
this.loadVouchers();
|
||||
}
|
||||
});
|
||||
onSharesChange(): void {
|
||||
// The verdict is cleared the moment the amount changes rather than when the re-check returns, so
|
||||
// a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays --
|
||||
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
|
||||
// new amount on its own.
|
||||
this.savings = null;
|
||||
this.errorMsg = '';
|
||||
this.sharesChanged.next(Number(this.requestedShares) || 0);
|
||||
}
|
||||
|
||||
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
||||
get amount(): number {
|
||||
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
|
||||
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
|
||||
return shares * price;
|
||||
}
|
||||
|
||||
get paymentMode(): string {
|
||||
return this.applySharesForm?.get('paymentMode')?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
|
||||
*
|
||||
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
|
||||
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
|
||||
*/
|
||||
private loadVouchers(): void {
|
||||
this.loadingVouchers = true;
|
||||
this.cashierService
|
||||
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
|
||||
this.loadingVouchers = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loadingVouchers = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
this.vouchers = res.unappliedCollections || [];
|
||||
if (this.vouchers.length === 1) {
|
||||
this.selectedVoucherIds = [this.vouchers[0].id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isVoucherSelected(id: number): boolean {
|
||||
return this.selectedVoucherIds.includes(id);
|
||||
}
|
||||
|
||||
toggleVoucher(id: number): void {
|
||||
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
|
||||
...this.selectedVoucherIds,
|
||||
id
|
||||
];
|
||||
this.voucherError = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the ticked receipts come to.
|
||||
*
|
||||
* Shown live, because the submission only goes through when the total covers the tranche, and
|
||||
* adding the rows up by eye is exactly what leads to a rejected submission.
|
||||
*/
|
||||
get selectedVoucherTotal(): number {
|
||||
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a voucher number the member quotes and adds it to the list.
|
||||
*
|
||||
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on
|
||||
* the slip in their hand, which is faster than finding it among their receipts. The lookup is
|
||||
* deliberately not restricted to this member, so a voucher belonging to somebody else is
|
||||
* surfaced here as a plain mismatch rather than silently ignored.
|
||||
*/
|
||||
lookupVoucher(voucherNo: string): void {
|
||||
const trimmed = (voucherNo || '').trim();
|
||||
const glAccountNo = this.glAccountNo.trim();
|
||||
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
|
||||
chooseFunding(source: FundingSource): void {
|
||||
if (this.funding === source) {
|
||||
return;
|
||||
}
|
||||
this.lookingUpVoucher = true;
|
||||
this.voucherError = '';
|
||||
this.funding = source;
|
||||
this.receipt = null;
|
||||
this.savings = null;
|
||||
this.errorMsg = '';
|
||||
if (source === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
} else if (!this.available.length) {
|
||||
this.loadReceipts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
|
||||
* ones still belonging to nobody — as one list.
|
||||
*
|
||||
* Nothing is pre-selected, not even when a single receipt is listed: which receipt funds the
|
||||
* tranche is a choice the person applying it makes, not one they inherit from a pre-ticked row
|
||||
* they never looked at.
|
||||
*/
|
||||
private loadReceipts(): void {
|
||||
this.loadingReceipts = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
|
||||
.getShareCollectionReceipts(this.data.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
|
||||
this.lookingUpVoucher = false;
|
||||
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
|
||||
this.loadingReceipts = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.lookingUpVoucher = false;
|
||||
this.loadingReceipts = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
|
||||
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
this.vouchers = [
|
||||
res,
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
this.available = [
|
||||
...(res.own || []).map((row: any) => receiptFromOwn(row)),
|
||||
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
|
||||
];
|
||||
if (!this.isVoucherSelected(res.id)) {
|
||||
this.selectedVoucherIds = [
|
||||
...this.selectedVoucherIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
|
||||
* a voucher number.
|
||||
*
|
||||
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
|
||||
* so it has to be attributed before it can fund anything. That attribution is exactly the
|
||||
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
|
||||
* choice against a member on screen, rather than an Attach in one table and a tick in another.
|
||||
*/
|
||||
pick(option: ReceiptOption): void {
|
||||
if (this.attaching || this.submitting) {
|
||||
return;
|
||||
}
|
||||
if (this.receipt?.key === option.key) {
|
||||
this.receipt = null;
|
||||
return;
|
||||
}
|
||||
if (option.collectionId !== null) {
|
||||
this.receipt = option;
|
||||
return;
|
||||
}
|
||||
|
||||
this.attaching = option.key;
|
||||
this.errorMsg = '';
|
||||
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
|
||||
this.attaching = null;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
// Attached now, so it takes its real row's place in the pool -- picking it again must never
|
||||
// run the attach a second time.
|
||||
const attached = receiptFromOwn(res);
|
||||
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
|
||||
this.receipt = attached;
|
||||
});
|
||||
}
|
||||
|
||||
clearReceipt(): void {
|
||||
this.receipt = null;
|
||||
}
|
||||
|
||||
/** What the chosen receipt is still missing, zero once it covers the tranche. */
|
||||
get shortfall(): number {
|
||||
const short = this.amount - Number(this.receipt?.amount || 0);
|
||||
return this.receipt && short > 0 ? short : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member's savings can cover this tranche.
|
||||
*
|
||||
@@ -331,104 +261,215 @@ export class ApplySharesComponent implements OnInit {
|
||||
* submission will be valued at. Deciding it in the browser would let the two drift apart.
|
||||
*/
|
||||
private checkSavings(): void {
|
||||
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
|
||||
const shares = Number(this.requestedShares) || 0;
|
||||
this.checkingSavings = true;
|
||||
this.sharesService
|
||||
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
|
||||
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.checkingSavings = false;
|
||||
if (!res) {
|
||||
this.savingsEligibility = {
|
||||
eligible: false,
|
||||
reason: 'Could not check the savings account linked to this share account.',
|
||||
savingsAccountNo: null,
|
||||
savingsBalance: null
|
||||
};
|
||||
return;
|
||||
}
|
||||
this.savingsEligibility = {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
};
|
||||
this.savings = res
|
||||
? {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
}
|
||||
: {
|
||||
eligible: false,
|
||||
reason: 'Could not check the savings account linked to this share account.',
|
||||
savingsAccountNo: null,
|
||||
savingsBalance: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
|
||||
* savings that can cover it.
|
||||
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
|
||||
* can.
|
||||
*
|
||||
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
|
||||
* at the counter instead of being refused after the member has left.
|
||||
* Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter
|
||||
* instead of being refused after the member has left.
|
||||
*/
|
||||
get fundingReady(): boolean {
|
||||
if (this.paymentMode === 'CASH') {
|
||||
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
|
||||
if (this.funding === 'CASH') {
|
||||
return !!this.receipt && this.shortfall === 0;
|
||||
}
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
return !!this.savingsEligibility?.eligible;
|
||||
if (this.funding === 'SAVINGS') {
|
||||
return !!this.savings?.eligible;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
|
||||
get voucherShortfall(): number {
|
||||
const short = this.amount - this.selectedVoucherTotal;
|
||||
return short > 0 ? short : 0;
|
||||
get canSubmit(): boolean {
|
||||
return (
|
||||
this.amount > 0 &&
|
||||
!!this.requestedDate &&
|
||||
!!this.resolutionNumber.trim() &&
|
||||
!!this.resolutionDate &&
|
||||
this.fundingReady &&
|
||||
!this.submitting
|
||||
);
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
|
||||
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
|
||||
|
||||
searchOpen = false;
|
||||
searchVoucherNo = '';
|
||||
searchGlAccountNo = '';
|
||||
searching = false;
|
||||
|
||||
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
|
||||
glAccounts: any[] = [];
|
||||
loadingGlAccounts = true;
|
||||
|
||||
openSearch(): void {
|
||||
this.searchOpen = true;
|
||||
this.searchVoucherNo = '';
|
||||
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
closeSearch(): void {
|
||||
this.searchOpen = false;
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
runSearch(): void {
|
||||
const voucher = this.searchVoucherNo.trim();
|
||||
const glAccountNo = this.searchGlAccountNo.trim();
|
||||
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
|
||||
// not that none is required.
|
||||
if (!voucher || !glAccountNo || this.searching) {
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.errorMsg = '';
|
||||
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
|
||||
this.searching = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const found = receiptFromOwn(res);
|
||||
this.available = [
|
||||
found,
|
||||
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
|
||||
];
|
||||
this.receipt = found;
|
||||
this.searchOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
private findVoucher(voucher: string, glAccountNo: string) {
|
||||
// No client-match check: any receipt can fund any share account, regardless of which member it
|
||||
// was first recorded against.
|
||||
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
viewing: ReceiptOption | null = null;
|
||||
|
||||
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
|
||||
* picked in the search box, which is unrelated screen state naming a different account. */
|
||||
viewingGlEntries: any[] | null = null;
|
||||
loadingGlEntries = false;
|
||||
|
||||
openDetail(option: ReceiptOption, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (option.collectionId === null) {
|
||||
return;
|
||||
}
|
||||
this.viewing = option;
|
||||
this.viewingGlEntries = null;
|
||||
this.loadingGlEntries = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceGlEntries(option.collectionId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.viewingGlEntries = res?.entries || [];
|
||||
this.loadingGlEntries = false;
|
||||
});
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.viewing = null;
|
||||
this.viewingGlEntries = null;
|
||||
}
|
||||
|
||||
// ── Submission ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
|
||||
* which is what moves the money out of Sundry into share capital.
|
||||
*/
|
||||
submit() {
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
const formValue = this.applySharesForm.value;
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
const data: any = {
|
||||
requestedShares: formValue.requestedShares,
|
||||
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
|
||||
unitPrice: this.applySharesForm.get('unitPrice').value,
|
||||
resolutionNumber: formValue.resolutionNumber,
|
||||
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
|
||||
fundingSource: formValue.paymentMode,
|
||||
const payload: any = {
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
|
||||
unitPrice: this.unitPrice,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
|
||||
fundingSource: this.funding,
|
||||
dateFormat,
|
||||
locale
|
||||
locale: this.settingsService.language.code
|
||||
};
|
||||
if (formValue.paymentMode === 'CASH') {
|
||||
// First receipt leads, the rest ride along; the server settles them as one funding.
|
||||
const [
|
||||
leadId,
|
||||
...additionalCollectionIds
|
||||
] = this.selectedVoucherIds;
|
||||
data.advanceCollectionId = leadId;
|
||||
if (additionalCollectionIds.length > 0) {
|
||||
data.additionalCollectionIds = additionalCollectionIds;
|
||||
}
|
||||
if (this.funding === 'CASH' && this.receipt) {
|
||||
payload.advanceCollectionId = this.receipt.collectionId;
|
||||
}
|
||||
|
||||
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
||||
}
|
||||
});
|
||||
this.sharesService
|
||||
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.dialogRef.close(res || true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
|
||||
glLabel(option: ReceiptOption): string {
|
||||
return receiptGlLabel(option, this.glAccounts);
|
||||
}
|
||||
|
||||
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
|
||||
private isoOf(date: Date): string {
|
||||
const local = new Date(date);
|
||||
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A yyyy-MM-dd back into a local Date.
|
||||
*
|
||||
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
|
||||
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
|
||||
* not something a date picker should be able to produce.
|
||||
*/
|
||||
private dateOf(iso: string): Date {
|
||||
const [
|
||||
year,
|
||||
month,
|
||||
day
|
||||
] = iso.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
@if (actions['Undo Approval']) {
|
||||
<mifosx-undo-approval-shares-account></mifosx-undo-approval-shares-account>
|
||||
}
|
||||
@if (actions['Apply Additional Shares']) {
|
||||
<mifosx-apply-shares></mifosx-apply-shares>
|
||||
}
|
||||
@if (actions['Redeem Shares']) {
|
||||
<mifosx-redeem-shares></mifosx-redeem-shares>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { RejectSharesAccountComponent } from './reject-shares-account/reject-sha
|
||||
import { CloseSharesAccountComponent } from './close-shares-account/close-shares-account.component';
|
||||
import { ActivateSharesAccountComponent } from './activate-shares-account/activate-shares-account.component';
|
||||
import { UndoApprovalSharesAccountComponent } from './undo-approval-shares-account/undo-approval-shares-account.component';
|
||||
import { ApplySharesComponent } from './apply-shares/apply-shares.component';
|
||||
import { RedeemSharesComponent } from './redeem-shares/redeem-shares.component';
|
||||
import { ApproveSharesComponent } from './approve-shares/approve-shares.component';
|
||||
import { RejectSharesComponent } from './reject-shares/reject-shares.component';
|
||||
@@ -37,7 +36,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
CloseSharesAccountComponent,
|
||||
ActivateSharesAccountComponent,
|
||||
UndoApprovalSharesAccountComponent,
|
||||
ApplySharesComponent,
|
||||
RedeemSharesComponent,
|
||||
ApproveSharesComponent,
|
||||
RejectSharesComponent
|
||||
@@ -55,7 +53,6 @@ export class SharesAccountActionsComponent {
|
||||
Close: boolean;
|
||||
Activate: boolean;
|
||||
'Undo Approval': boolean;
|
||||
'Apply Additional Shares': boolean;
|
||||
'Redeem Shares': boolean;
|
||||
'Approve Additional Shares': boolean;
|
||||
'Reject Additional Shares': boolean;
|
||||
@@ -65,7 +62,6 @@ export class SharesAccountActionsComponent {
|
||||
Close: false,
|
||||
Activate: false,
|
||||
'Undo Approval': false,
|
||||
'Apply Additional Shares': false,
|
||||
'Redeem Shares': false,
|
||||
'Approve Additional Shares': false,
|
||||
'Reject Additional Shares': false
|
||||
|
||||
Reference in New Issue
Block a user