feat: implement share account actions, view components, and pending checker tasks
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of an account's `purchasedShares` rows belong in a record of money that has moved.
|
||||||
|
*
|
||||||
|
* `purchasedShares` is not a transaction list — it is every row in `m_share_account_transactions`,
|
||||||
|
* which is also where a request *awaiting* approval is parked. A first application and an
|
||||||
|
* additional-share request are stored identically: same table, same APPLIED status, differing only
|
||||||
|
* in when they were raised. So a single predicate covers both, and neither needs special-casing.
|
||||||
|
*
|
||||||
|
* Excluded:
|
||||||
|
*
|
||||||
|
* - **Charge payments** — a fee, not a share movement; the Charges tab is where those belong.
|
||||||
|
* - **Applied (pending approval)** — nobody has approved it and, on the additional-share route, the
|
||||||
|
* money is still sitting in Share Sundry. Listing it as a transaction reads as though the member
|
||||||
|
* has paid and holds the shares when they hold neither. Pending requests appear under Approve
|
||||||
|
* Additional Shares, which is the view that exists to act on them.
|
||||||
|
* - **Rejected** — a request that was refused never became a movement.
|
||||||
|
*/
|
||||||
|
export function isSettledShareTransaction(transaction: any): boolean {
|
||||||
|
const type = transaction?.type?.value;
|
||||||
|
const statusCode = transaction?.status?.code;
|
||||||
|
|
||||||
|
if (type === 'Charge Payment') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return statusCode !== 'purchasedSharesStatusType.applied' && statusCode !== 'purchasedSharesStatusType.rejected';
|
||||||
|
}
|
||||||
+2
-1
@@ -70,8 +70,9 @@ export class ActivateSharesAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createActivateSharesAccountForm() {
|
createActivateSharesAccountForm() {
|
||||||
this.activateSharesAccountForm = this.formBuilder.group({
|
this.activateSharesAccountForm = this.formBuilder.group({
|
||||||
|
// Opens on the business date; see the note in ApproveSharesAccountComponent.
|
||||||
activatedDate: [
|
activatedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,42 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</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-form-field>
|
||||||
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
|
||||||
<input matInput required formControlName="requestedShares" />
|
<input matInput type="number" min="1" required formControlName="requestedShares" />
|
||||||
@if (applySharesForm.controls.requestedShares.hasError('required')) {
|
@if (applySharesForm.controls.requestedShares.hasError('required')) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
|
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
@@ -44,8 +77,194 @@
|
|||||||
|
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
|
||||||
<input matInput required formControlName="unitPrice" />
|
<input matInput formControlName="unitPrice" />
|
||||||
</mat-form-field>
|
</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="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() || 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>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
@@ -53,8 +272,8 @@
|
|||||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" [disabled]="!applySharesForm.valid">
|
<button mat-raised-button color="primary" [disabled]="!canSubmit">
|
||||||
{{ 'labels.buttons.Submit' | translate }}
|
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,6 +6,207 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
$primary: #1a3a6b;
|
||||||
|
$border: #d8e0ec;
|
||||||
|
$muted: #6b7a90;
|
||||||
|
$danger: #b3261e;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 37rem;
|
max-width: 37rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-mode {
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-mode-label {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: $muted;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-mode-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funding-intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $muted;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-lookup {
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,30 +7,63 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { SharesService } from 'app/shares/shares.service';
|
import { SharesService } from 'app/shares/shares.service';
|
||||||
|
import { CashierService } from 'app/cashier/cashier.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply Shares Component
|
* 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>
|
||||||
|
*
|
||||||
|
* 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>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>
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-apply-shares',
|
selector: 'mifosx-apply-shares',
|
||||||
templateUrl: './apply-shares.component.html',
|
templateUrl: './apply-shares.component.html',
|
||||||
styleUrls: ['./apply-shares.component.scss'],
|
styleUrls: ['./apply-shares.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatRadioModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatIconModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ApplySharesComponent implements OnInit {
|
export class ApplySharesComponent implements OnInit {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private sharesService = inject(SharesService);
|
private sharesService = inject(SharesService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -48,14 +81,33 @@ export class ApplySharesComponent implements OnInit {
|
|||||||
/** Shares Account Id */
|
/** Shares Account Id */
|
||||||
accountId: any;
|
accountId: any;
|
||||||
|
|
||||||
|
/** Submission in flight, so the form cannot be posted twice. */
|
||||||
|
submitting = false;
|
||||||
|
/** Anything that stopped the submission, shown above the buttons. */
|
||||||
|
errorMsg = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FormBuilder} formBuilder Form Builder
|
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
|
||||||
* @param {SharesService} sharesService Shares Service
|
*
|
||||||
* @param {Dates} dateUtils Date Utils
|
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
|
||||||
* @param {ActivatedRoute} route Activated Route
|
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
|
||||||
* @param {Router} router Router
|
* rather than sending the member back to the counter for a single combined receipt.
|
||||||
* @param {SettingsService} settingsService Settings Service
|
|
||||||
*/
|
*/
|
||||||
|
vouchers: any[] = [];
|
||||||
|
selectedVoucherIds: number[] = [];
|
||||||
|
loadingVouchers = false;
|
||||||
|
lookingUpVoucher = false;
|
||||||
|
voucherError = '';
|
||||||
|
|
||||||
|
/** What the member's savings can cover, for the savings route. */
|
||||||
|
savingsEligibility: {
|
||||||
|
eligible: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
savingsAccountNo: string | null;
|
||||||
|
savingsBalance: number | null;
|
||||||
|
} | null = null;
|
||||||
|
checkingSavings = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
|
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
|
||||||
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
|
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
|
||||||
@@ -63,54 +115,311 @@ export class ApplySharesComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the apply shares form.
|
|
||||||
* Fetching data from service as action buttons malfunction
|
|
||||||
* in clients view upon using a common resolver.
|
|
||||||
*/
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.createApplySharesAccountForm();
|
this.createApplySharesAccountForm();
|
||||||
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
|
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
|
||||||
|
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the apply shares form.
|
* 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() {
|
createApplySharesAccountForm() {
|
||||||
this.applySharesForm = this.formBuilder.group({
|
this.applySharesForm = this.formBuilder.group({
|
||||||
requestedDate: [
|
requestedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
requestedShares: [
|
requestedShares: [
|
||||||
'',
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
unitPrice: [{ value: '', disabled: true }]
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits the form and applies additional shares to the share account,
|
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
|
||||||
* if successful redirects to the share account.
|
*
|
||||||
|
* 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();
|
||||||
|
if (!trimmed || this.lookingUpVoucher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lookingUpVoucher = true;
|
||||||
|
this.voucherError = '';
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getShareAdvanceByVoucher(trimmed, this.sharesAccountData.clientId)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
|
||||||
|
this.lookingUpVoucher = false;
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.lookingUpVoucher = 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)
|
||||||
|
];
|
||||||
|
if (!this.isVoucherSelected(res.id)) {
|
||||||
|
this.selectedVoucherIds = [
|
||||||
|
...this.selectedVoucherIds,
|
||||||
|
res.id
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the member's savings can cover this tranche.
|
||||||
|
*
|
||||||
|
* Asks the tranche-scoped endpoint rather than judging it here: the server prices the shares from
|
||||||
|
* the product's market price, so the figure the balance is checked against is the same one the
|
||||||
|
* 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;
|
||||||
|
this.checkingSavings = true;
|
||||||
|
this.sharesService
|
||||||
|
.getAdditionalSharesSavingsEligibility(this.accountId, 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)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
|
||||||
|
* savings that can cover it.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get fundingReady(): boolean {
|
||||||
|
if (this.paymentMode === 'CASH') {
|
||||||
|
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
|
||||||
|
}
|
||||||
|
if (this.paymentMode === 'SAVINGS') {
|
||||||
|
return !!this.savingsEligibility?.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.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
const applySharesFormData = this.applySharesForm.value;
|
if (!this.canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
|
||||||
|
const formValue = this.applySharesForm.value;
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const prevRequestedDate: Date = this.applySharesForm.value.requestedDate;
|
|
||||||
if (applySharesFormData.requestedDate instanceof Date) {
|
const data: any = {
|
||||||
applySharesFormData.requestedDate = this.dateUtils.formatDate(prevRequestedDate, dateFormat);
|
requestedShares: formValue.requestedShares,
|
||||||
}
|
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
|
||||||
const data = {
|
|
||||||
...applySharesFormData,
|
|
||||||
unitPrice: this.applySharesForm.get('unitPrice').value,
|
unitPrice: this.applySharesForm.get('unitPrice').value,
|
||||||
|
resolutionNumber: formValue.resolutionNumber,
|
||||||
|
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
|
||||||
|
fundingSource: formValue.paymentMode,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe(() => {
|
if (formValue.paymentMode === 'CASH') {
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readError(err: any, fallback: string): string {
|
||||||
|
return (
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -72,8 +72,11 @@ export class ApproveSharesAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createApproveSharesAccountForm() {
|
createApproveSharesAccountForm() {
|
||||||
this.approveSharesAccountForm = this.formBuilder.group({
|
this.approveSharesAccountForm = this.formBuilder.group({
|
||||||
|
// Opens on the business date rather than blank: an approval is recorded the day it is given,
|
||||||
|
// so this is what the clerk would type anyway. Still editable, and still capped at the
|
||||||
|
// business date, for an approval being entered after the fact.
|
||||||
approvedDate: [
|
approvedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
|
|||||||
@@ -26,6 +26,29 @@
|
|||||||
<td mat-cell *matCellDef="let share">{{ share.purchasedPrice }}</td>
|
<td mat-cell *matCellDef="let share">{{ share.purchasedPrice }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="amount">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let share">{{ trancheAmount(share) | number: '1.2-2' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- The board resolution authorising this particular tranche, captured on the apply form. -->
|
||||||
|
<ng-container matColumnDef="resolution">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Resolution Number' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let share">
|
||||||
|
{{ share.resolutionNumber || '—' }}
|
||||||
|
@if (share.meetingDate) {
|
||||||
|
<span class="resolution-date">({{ share.meetingDate | dateFormat }})</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Cash means the money is already in Share Sundry against a voucher; Savings means it was
|
||||||
|
debited from the member when the request was raised. Either way approval moves it out. -->
|
||||||
|
<ng-container matColumnDef="fundingSource">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Option' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let share">{{ fundingLabel(share) || '—' }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let share">
|
<td mat-cell *matCellDef="let share">
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ table {
|
|||||||
margin: 4px;
|
margin: 4px;
|
||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The meeting date rides alongside the resolution number rather than taking a column of its
|
||||||
|
own — it is only ever read together with the number it belongs to. */
|
||||||
|
.resolution-date {
|
||||||
|
color: #6b7a90;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { ApproveShareDialogComponent } from './approve-share-dialog/approve-shar
|
|||||||
/** Custom Serices */
|
/** Custom Serices */
|
||||||
import { SharesService } from 'app/shares/shares.service';
|
import { SharesService } from 'app/shares/shares.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { NgClass } from '@angular/common';
|
import { DecimalPipe, NgClass } from '@angular/common';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
@@ -63,7 +63,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatRow,
|
MatRow,
|
||||||
MatPaginator,
|
MatPaginator,
|
||||||
StatusLookupPipe,
|
StatusLookupPipe,
|
||||||
DateFormatPipe
|
DateFormatPipe,
|
||||||
|
DecimalPipe
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ApproveSharesComponent implements OnInit {
|
export class ApproveSharesComponent implements OnInit {
|
||||||
@@ -79,11 +80,22 @@ export class ApproveSharesComponent implements OnInit {
|
|||||||
accountId: any;
|
accountId: any;
|
||||||
/** Shares account data. */
|
/** Shares account data. */
|
||||||
sharesData: any[];
|
sharesData: any[];
|
||||||
/** Columns to be displayed in shares table. */
|
/**
|
||||||
|
* Columns to be displayed in shares table.
|
||||||
|
*
|
||||||
|
* Pending tranches appear here and nowhere else — the Transactions and Passbook tabs list only
|
||||||
|
* settled movements — so this table has to carry everything the approval turns on: what the
|
||||||
|
* tranche is worth, the board resolution authorising it, and how it was funded. Approving posts
|
||||||
|
* Dr Share Sundry / Cr Share Capital, and an approver who cannot see the amount or the funding
|
||||||
|
* route is being asked to authorise a posting they cannot check.
|
||||||
|
*/
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'transactionDate',
|
'transactionDate',
|
||||||
'totalShares',
|
'totalShares',
|
||||||
'redeemedPrice',
|
'redeemedPrice',
|
||||||
|
'amount',
|
||||||
|
'resolution',
|
||||||
|
'fundingSource',
|
||||||
'status',
|
'status',
|
||||||
'approve'
|
'approve'
|
||||||
];
|
];
|
||||||
@@ -120,6 +132,51 @@ export class ApproveSharesComponent implements OnInit {
|
|||||||
this.setShares();
|
this.setShares();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tranche is worth. Taken from the transaction's own `amount` where the server sends
|
||||||
|
* one, falling back to shares x price — the same product the apply form showed the member.
|
||||||
|
*/
|
||||||
|
trancheAmount(share: any): number {
|
||||||
|
const amount = Number(share?.amount);
|
||||||
|
if (!Number.isNaN(amount) && amount > 0) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
return (Number(share?.numberOfShares) || 0) * (Number(share?.purchasedPrice) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this tranche was funded, in the words the approver chose on the apply form.
|
||||||
|
*
|
||||||
|
* Blank for tranches raised before funding was captured, which is honest: those genuinely have
|
||||||
|
* no recorded route, and inventing one here would misrepresent them.
|
||||||
|
*/
|
||||||
|
fundingLabel(share: any): string {
|
||||||
|
switch (share?.fundingSource) {
|
||||||
|
case 'CASH': {
|
||||||
|
// Says how many receipts when a member paid in instalments, so the approver knows to expect
|
||||||
|
// several vouchers behind one tranche rather than suspecting a duplicate.
|
||||||
|
const count = this.voucherCount(share);
|
||||||
|
return count > 1 ? `Cash (${count} vouchers)` : 'Cash';
|
||||||
|
}
|
||||||
|
case 'SAVINGS':
|
||||||
|
return 'Savings';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many receipts fund this tranche — the lead one plus any further instalments. */
|
||||||
|
private voucherCount(share: any): number {
|
||||||
|
if (!share?.advanceCollectionId) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const extra = (share.additionalCollectionIds || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part: string) => part.trim())
|
||||||
|
.filter((part: string) => part.length > 0);
|
||||||
|
return 1 + extra.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the data source, paginator and sorter for shares table.
|
* Initializes the data source, paginator and sorter for shares table.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-1
@@ -72,8 +72,9 @@ export class CloseSharesAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createCloseSharesAccountForm() {
|
createCloseSharesAccountForm() {
|
||||||
this.closeSharesAccountForm = this.formBuilder.group({
|
this.closeSharesAccountForm = this.formBuilder.group({
|
||||||
|
// Opens on the business date; see the note in ApproveSharesAccountComponent.
|
||||||
closedDate: [
|
closedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ export class RedeemSharesComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createRedeemSharesAccountForm() {
|
createRedeemSharesAccountForm() {
|
||||||
this.redeemSharesForm = this.formBuilder.group({
|
this.redeemSharesForm = this.formBuilder.group({
|
||||||
|
// Opens on the business date; see the note in ApproveSharesAccountComponent.
|
||||||
requestedDate: [
|
requestedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
requestedShares: [
|
requestedShares: [
|
||||||
|
|||||||
+2
-1
@@ -72,8 +72,9 @@ export class RejectSharesAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createRejectSharesAccountForm() {
|
createRejectSharesAccountForm() {
|
||||||
this.rejectSharesAccountForm = this.formBuilder.group({
|
this.rejectSharesAccountForm = this.formBuilder.group({
|
||||||
|
// Opens on the business date; see the note in ApproveSharesAccountComponent.
|
||||||
rejectedDate: [
|
rejectedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
|
|||||||
+4
-1
@@ -96,8 +96,11 @@ export class SharesAccountDetailsStepComponent implements OnInit {
|
|||||||
'',
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
|
// Opens on the business date rather than blank: an application 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. Still editable, for an application being entered after the fact.
|
||||||
submittedDate: [
|
submittedDate: [
|
||||||
'',
|
this.settingsService.businessDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
externalId: [''],
|
externalId: [''],
|
||||||
|
|||||||
+23
-20
@@ -10,17 +10,18 @@
|
|||||||
|
|
||||||
<mat-dialog-content>
|
<mat-dialog-content>
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Enter the voucher number the member was given, or tick the receipts below, to settle against share account
|
Tick the receipts that make up this payment to settle share account <b>{{ data.accountNo }}</b
|
||||||
<b>{{ data.accountNo }}</b
|
>, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in
|
||||||
>. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share
|
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.
|
||||||
capital, and any entry fee is posted to fee income.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is
|
||||||
|
holding that is quicker to type than to find. -->
|
||||||
<div class="voucher-lookup">
|
<div class="voucher-lookup">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="voucher-input"
|
class="voucher-input"
|
||||||
placeholder="Voucher no. (e.g. 000123)"
|
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
|
||||||
[(ngModel)]="voucherNo"
|
[(ngModel)]="voucherNo"
|
||||||
[disabled]="lookingUp || submitting"
|
[disabled]="lookingUp || submitting"
|
||||||
(keyup.enter)="lookupVoucher()"
|
(keyup.enter)="lookupVoucher()"
|
||||||
@@ -51,10 +52,11 @@
|
|||||||
<div class="empty">
|
<div class="empty">
|
||||||
<mat-icon>info_outline</mat-icon>
|
<mat-icon>info_outline</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<b>No unapplied receipt for this member.</b>
|
<b>No unapplied receipt for this member today.</b>
|
||||||
<div class="empty-hint">
|
<div class="empty-hint">
|
||||||
Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry GL. Once a
|
If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from
|
||||||
manager approves it, enter the voucher number above.
|
Cashier › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the
|
||||||
|
voucher appears here.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
<table class="receipt-table">
|
<table class="receipt-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th class="col-tick"></th>
|
||||||
<th class="ta-left">Voucher / Receipt</th>
|
<th class="ta-left">Voucher / Receipt</th>
|
||||||
<th class="ta-left">Date</th>
|
<th class="ta-left">Date</th>
|
||||||
<th class="ta-right">Amount</th>
|
<th class="ta-right">Amount</th>
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@for (r of receipts; track r.id) {
|
@for (r of receipts; track r.id) {
|
||||||
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
|
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
|
||||||
<td class="ta-center">
|
<td class="ta-center col-tick">
|
||||||
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
|
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
|
||||||
against one application, and all of them settle it together. -->
|
against one application, and all of them settle it together. -->
|
||||||
<input
|
<input
|
||||||
@@ -83,28 +85,29 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</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">{{ r.collection_date }}</td>
|
||||||
<td class="ta-right">
|
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
<b>{{ r.amount }}</b>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@if (selectedIds.length > 1) {
|
<!-- Shown from the first tick rather than the second: the total against what the account needs
|
||||||
|
is what decides whether the settlement is accepted, so it should be on screen as soon as
|
||||||
|
there is anything to total. -->
|
||||||
|
@if (selectedIds.length > 0) {
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="total-row">
|
<tr class="total-row">
|
||||||
<td></td>
|
<td class="col-tick"></td>
|
||||||
<td class="ta-left" colspan="2">{{ selectedIds.length }} receipts selected</td>
|
<td class="ta-left" colspan="2">
|
||||||
<td class="ta-right">
|
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
|
||||||
<b>{{ selectedTotal | number: '1.2-2' }}</b>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
}
|
}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="note">
|
<div class="note">
|
||||||
Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry
|
Their total must cover the account's share value plus any entry fee — anything paid over that is credited to the
|
||||||
fee — anything paid over that is credited to the member's savings account.
|
member's savings account.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+29
-4
@@ -75,19 +75,35 @@ $danger: #b3261e;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: #eef2f8;
|
background: #eef2f8;
|
||||||
color: $primary;
|
color: $primary;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
padding: 8px;
|
padding: 9px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 8px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid #eceff4;
|
border-bottom: 1px solid #eceff4;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Just wide enough for the box, so the voucher number starts near the left edge instead of
|
||||||
|
after a column of empty space. */
|
||||||
|
.col-tick {
|
||||||
|
width: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
@@ -102,15 +118,24 @@ $danger: #b3261e;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only appears once a second receipt is ticked, when adding the rows up by eye stops being
|
/* Tabular figures so the amounts line up digit for digit down the column and against the total
|
||||||
reliable — and the total is what decides whether the settlement is accepted. */
|
below them — the whole point of the column is that it can be read as a sum. */
|
||||||
|
.amount-cell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
tfoot .total-row {
|
tfoot .total-row {
|
||||||
|
background: #f4f7fb;
|
||||||
border-top: 2px solid #cbd5e1;
|
border-top: 2px solid #cbd5e1;
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
color: #1f3d7a;
|
color: #1f3d7a;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-12
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, Inject, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
@@ -16,6 +16,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { CashierService } from 'app/cashier/cashier.service';
|
import { CashierService } from 'app/cashier/cashier.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a Share Advance receipt to an activated share account.
|
* Applies a Share Advance receipt to an activated share account.
|
||||||
@@ -23,6 +25,14 @@ import { CashierService } from 'app/cashier/cashier.service';
|
|||||||
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
|
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
|
||||||
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
|
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
|
||||||
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
|
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
|
||||||
|
*
|
||||||
|
* The list shows the member's unapplied receipts for the business date, since a member settling an
|
||||||
|
* application at the counter has almost always paid for it today, and an older unapplied receipt of
|
||||||
|
* theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see
|
||||||
|
* {@link ngOnInit}.
|
||||||
|
*
|
||||||
|
* A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} —
|
||||||
|
* cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-apply-collection-dialog',
|
selector: 'mifosx-apply-collection-dialog',
|
||||||
@@ -40,6 +50,8 @@ import { CashierService } from 'app/cashier/cashier.service';
|
|||||||
})
|
})
|
||||||
export class ApplyCollectionDialogComponent implements OnInit {
|
export class ApplyCollectionDialogComponent implements OnInit {
|
||||||
private cashierService = inject(CashierService);
|
private cashierService = inject(CashierService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
@@ -56,7 +68,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
selectedIds: number[] = [];
|
selectedIds: number[] = [];
|
||||||
|
|
||||||
/** Voucher number quoted by the member, as an alternative to picking from the list. */
|
/** Voucher number quoted by the member, for a receipt the list does not show. */
|
||||||
voucherNo = '';
|
voucherNo = '';
|
||||||
lookingUp = false;
|
lookingUp = false;
|
||||||
|
|
||||||
@@ -75,15 +87,39 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
this.receipts = res.unappliedCollections || [];
|
const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd');
|
||||||
if (this.receipts.length === 1) {
|
this.receipts = (res.unappliedCollections || []).filter(
|
||||||
this.selectedIds = [this.receipts[0].id];
|
(receipt: any) => this.collectionDateOf(receipt) === today
|
||||||
}
|
);
|
||||||
|
// Deliberately nothing ticked, not even when a single receipt is listed. Applying a
|
||||||
|
// collection turns the member's money into share capital, so which receipt it comes out of
|
||||||
|
// should be a choice the clerk made rather than one they inherited from a pre-ticked box
|
||||||
|
// and never looked at.
|
||||||
}
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A receipt's collection date as {@code yyyy-MM-dd}.
|
||||||
|
*
|
||||||
|
* Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array
|
||||||
|
* — rather than assuming one, so the filter cannot silently match nothing if the serialiser
|
||||||
|
* behind this endpoint changes.
|
||||||
|
*/
|
||||||
|
private collectionDateOf(receipt: any): string {
|
||||||
|
const raw = receipt?.collection_date ?? receipt?.collectionDate;
|
||||||
|
if (Array.isArray(raw) && raw.length >= 3) {
|
||||||
|
const [
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day
|
||||||
|
] = raw;
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return typeof raw === 'string' ? raw.slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
|
||||||
isSelected(id: number): boolean {
|
isSelected(id: number): boolean {
|
||||||
return this.selectedIds.includes(id);
|
return this.selectedIds.includes(id);
|
||||||
}
|
}
|
||||||
@@ -107,9 +143,13 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the voucher number the member quotes into a receipt. The lookup is not restricted to
|
* Resolves the voucher number the member quotes into a receipt and adds it to the list.
|
||||||
* this member, so a voucher belonging to somebody else is surfaced here as a clear mismatch
|
*
|
||||||
* rather than failing later inside apply().
|
* The list is deliberately narrow — this member, today, unapplied — so the search is how a
|
||||||
|
* receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that
|
||||||
|
* is quicker to type than to find. The lookup itself is not restricted to this member, so a
|
||||||
|
* voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing
|
||||||
|
* later inside apply().
|
||||||
*/
|
*/
|
||||||
lookupVoucher(): void {
|
lookupVoucher(): void {
|
||||||
const voucher = this.voucherNo.trim();
|
const voucher = this.voucherNo.trim();
|
||||||
@@ -137,9 +177,9 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
|||||||
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
|
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show it in the table (replacing any stale copy) and add it to the selection, rather than
|
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which
|
||||||
// replacing what is already ticked — a looked-up voucher is usually the second half of a
|
// are left for the clerk: typing a voucher number IS the deliberate choice of that receipt,
|
||||||
// part-payment, not a change of mind about the first.
|
// so making them tick it again would be asking the same question twice.
|
||||||
this.receipts = [
|
this.receipts = [
|
||||||
res,
|
res,
|
||||||
...this.receipts.filter((r) => r.id !== res.id)
|
...this.receipts.filter((r) => r.id !== res.id)
|
||||||
@@ -150,6 +190,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
|||||||
res.id
|
res.id
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
this.voucherNo = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,14 +178,16 @@
|
|||||||
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-resolution text-left">
|
<td class="col-resolution text-left">
|
||||||
@if (isRedeem(txn)) {
|
<!-- The transaction's own resolution wherever it has one: a redeem always carries
|
||||||
{{ txn.resolutionNumber || '—' }}
|
one, and so does an additional-share purchase, authorised by its own board
|
||||||
|
meeting rather than the one the account was opened under. Only the original
|
||||||
|
application falls back to the account's resolution, having none of its own. -->
|
||||||
|
@if (txn.resolutionNumber) {
|
||||||
|
{{ txn.resolutionNumber }}
|
||||||
@if (txn.meetingDate) {
|
@if (txn.meetingDate) {
|
||||||
({{ txn.meetingDate | dateFormat }})
|
({{ txn.meetingDate | dateFormat }})
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Purchases are authorised by the share account's own board resolution, which is
|
|
||||||
held on the account rather than the transaction. -->
|
|
||||||
{{ resolutionNumber || '—' }}
|
{{ resolutionNumber || '—' }}
|
||||||
@if (resolutionDate) {
|
@if (resolutionDate) {
|
||||||
({{ resolutionDate | dateFormat }})
|
({{ resolutionDate | dateFormat }})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CurrencyPipe } from '@angular/common';
|
|||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { isSettledShareTransaction } from 'app/shares/share-transactions.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
|
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
|
||||||
@@ -81,9 +82,7 @@ export class SharesPassbookTabComponent implements OnInit {
|
|||||||
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
||||||
this.currentMarketPrice = account.currentMarketPrice || 0;
|
this.currentMarketPrice = account.currentMarketPrice || 0;
|
||||||
|
|
||||||
const transactions = (account.purchasedShares || []).filter(
|
const transactions = (account.purchasedShares || []).filter(isSettledShareTransaction);
|
||||||
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
|
||||||
);
|
|
||||||
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
|
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
|
||||||
this.filteredTransactions = [...this.allTransactions];
|
this.filteredTransactions = [...this.allTransactions];
|
||||||
});
|
});
|
||||||
|
|||||||
+42
@@ -99,6 +99,48 @@
|
|||||||
{{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
|
{{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- The allotments this certificate covers, one row each. A holding is built up over time —
|
||||||
|
the opening subscription, then each further tranche under its own board resolution — and
|
||||||
|
a certificate has to show when each block was allotted, not just the total. -->
|
||||||
|
@if (allotments.length > 0) {
|
||||||
|
<table class="allotment-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ta-left">Date of Allotment</th>
|
||||||
|
<th class="ta-right">Shares</th>
|
||||||
|
<th class="ta-right">Face Value</th>
|
||||||
|
<th class="ta-left">Share Numbers</th>
|
||||||
|
<th class="ta-left">Resolution No. / Meeting Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of allotments; track $index) {
|
||||||
|
<tr>
|
||||||
|
<td class="ta-left">{{ row.date | dateFormat }}</td>
|
||||||
|
<td class="ta-right">{{ row.shares }}</td>
|
||||||
|
<td class="ta-right">
|
||||||
|
{{ row.faceValue | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
|
||||||
|
</td>
|
||||||
|
<td class="ta-left">
|
||||||
|
{{ row.shareNumbersFrom.toLocaleString() }} – {{ row.shareNumbersTo.toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td class="ta-left">
|
||||||
|
{{ row.resolutionNumber || '—' }}
|
||||||
|
@if (row.meetingDate) {
|
||||||
|
<span class="allotment-meeting">({{ row.meetingDate | dateFormat }})</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="certificate-details-table">
|
||||||
|
<tbody>
|
||||||
<tr class="highlight-row">
|
<tr class="highlight-row">
|
||||||
<td colspan="2" class="lbl-col-highlight">TOTAL FACE VALUE</td>
|
<td colspan="2" class="lbl-col-highlight">TOTAL FACE VALUE</td>
|
||||||
<td colspan="2" class="val-col-highlight">
|
<td colspan="2" class="val-col-highlight">
|
||||||
|
|||||||
+48
@@ -206,6 +206,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Certificate Data Table */
|
/* Certificate Data Table */
|
||||||
|
|
||||||
|
/* The details grid and the allotment schedule read as one block, so the gap that used to sit under
|
||||||
|
the details table now sits under whichever table comes last. */
|
||||||
|
.certificate-details-table:not(:last-of-type) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.certificate-details-table {
|
.certificate-details-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -250,6 +257,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The allotment schedule — one row per block of shares allotted, in the same paper-and-ink palette
|
||||||
|
as the details grid so it reads as part of the certificate rather than a report pasted into it. */
|
||||||
|
.allotment-table {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2; /* Above the watermark, like the other certificate content. */
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ede8db;
|
||||||
|
background-color: #f7f5ee;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7b6f63;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid #ede8db;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #322f2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allotment-meeting {
|
||||||
|
color: #7b6f63;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Certificate Footer Seal / Signatures */
|
/* Certificate Footer Seal / Signatures */
|
||||||
.cert-footer {
|
.cert-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+75
@@ -61,6 +61,27 @@ export class ShareCertificateDialogComponent implements OnInit {
|
|||||||
issueDate: Date = new Date();
|
issueDate: Date = new Date();
|
||||||
todayDate: Date = new Date();
|
todayDate: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per approved purchase, oldest first — the allotments this certificate covers.
|
||||||
|
*
|
||||||
|
* A member's holding is built up over time: the opening subscription, then each further tranche,
|
||||||
|
* every one authorised by its own board resolution on its own date. Collapsing that into a single
|
||||||
|
* total and one "Date of Issue" loses exactly what a share certificate exists to record — when
|
||||||
|
* each block of shares was allotted, under which resolution, and which share numbers it covers.
|
||||||
|
* The running range is what lets a member's 20 shares be read as 10 allotted in August and 10 in
|
||||||
|
* September rather than as one undated block.
|
||||||
|
*/
|
||||||
|
allotments: {
|
||||||
|
date: any;
|
||||||
|
shares: number;
|
||||||
|
unitPrice: number;
|
||||||
|
faceValue: number;
|
||||||
|
resolutionNumber: string;
|
||||||
|
meetingDate: any;
|
||||||
|
shareNumbersFrom: number;
|
||||||
|
shareNumbersTo: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sharesAccountData = this.data.sharesAccountData;
|
this.sharesAccountData = this.data.sharesAccountData;
|
||||||
this.clientId = String(this.data.clientId);
|
this.clientId = String(this.data.clientId);
|
||||||
@@ -82,6 +103,18 @@ export class ShareCertificateDialogComponent implements OnInit {
|
|||||||
const endNum = startNum + approvedShares - 1;
|
const endNum = startNum + approvedShares - 1;
|
||||||
this.shareNumbersRange = approvedShares > 0 ? `${startNum.toLocaleString()} – ${endNum.toLocaleString()}` : 'None';
|
this.shareNumbersRange = approvedShares > 0 ? `${startNum.toLocaleString()} – ${endNum.toLocaleString()}` : 'None';
|
||||||
|
|
||||||
|
this.buildAllotments(startNum);
|
||||||
|
|
||||||
|
// The certificate is issued as of the most recent allotment, not the account's activation: a
|
||||||
|
// certificate reprinted after a further tranche covers those shares too, and dating it from
|
||||||
|
// activation would say the later shares were issued before they existed.
|
||||||
|
if (this.allotments.length > 0) {
|
||||||
|
const latest = this.allotments[this.allotments.length - 1];
|
||||||
|
if (latest.date) {
|
||||||
|
this.issueDate = new Date(latest.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate cumulative dividends
|
// Calculate cumulative dividends
|
||||||
if (this.sharesAccountData.dividends) {
|
if (this.sharesAccountData.dividends) {
|
||||||
this.cumulativeDividend = this.sharesAccountData.dividends.reduce(
|
this.cumulativeDividend = this.sharesAccountData.dividends.reduce(
|
||||||
@@ -124,6 +157,48 @@ export class ShareCertificateDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the allotment rows from the account's approved purchases.
|
||||||
|
*
|
||||||
|
* Only approved purchases count. A pending tranche has not been allotted — the board has not
|
||||||
|
* approved it and, on the cash and savings routes alike, the money is still sitting in Share
|
||||||
|
* Sundry — so putting it on a certificate would certify shares the member does not yet hold.
|
||||||
|
* Rejected ones and charge payments are not allotments at all.
|
||||||
|
*
|
||||||
|
* Share numbers run consecutively in purchase order, so each row's range picks up where the last
|
||||||
|
* left off and the ranges together reconcile to the account's total holding.
|
||||||
|
*
|
||||||
|
* @param startNum the first share number of the account's holding.
|
||||||
|
*/
|
||||||
|
private buildAllotments(startNum: number): void {
|
||||||
|
const purchases = (this.sharesAccountData.purchasedShares || [])
|
||||||
|
.filter(
|
||||||
|
(txn: any) => txn?.type?.value === 'Purchase' && txn?.status?.code === 'purchasedSharesStatusType.approved'
|
||||||
|
)
|
||||||
|
.slice()
|
||||||
|
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
||||||
|
|
||||||
|
let nextShareNumber = startNum;
|
||||||
|
this.allotments = purchases.map((txn: any) => {
|
||||||
|
const shares = Number(txn.numberOfShares) || 0;
|
||||||
|
const unitPrice = Number(txn.purchasedPrice) || 0;
|
||||||
|
const from = nextShareNumber;
|
||||||
|
nextShareNumber += shares;
|
||||||
|
return {
|
||||||
|
date: txn.purchasedDate,
|
||||||
|
shares,
|
||||||
|
unitPrice,
|
||||||
|
faceValue: shares * unitPrice,
|
||||||
|
// The account's own resolution stands in for the opening subscription, which carries none of
|
||||||
|
// its own; each additional tranche has the resolution captured when it was applied for.
|
||||||
|
resolutionNumber: txn.resolutionNumber || this.sharesAccountData.resolutionNumber || '',
|
||||||
|
meetingDate: txn.meetingDate || this.sharesAccountData.resolutionDate || null,
|
||||||
|
shareNumbersFrom: from,
|
||||||
|
shareNumbersTo: from + shares - 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
formatAddress(addr: any): string {
|
formatAddress(addr: any): string {
|
||||||
if (!addr) return '';
|
if (!addr) return '';
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -62,14 +62,16 @@
|
|||||||
<ng-container matColumnDef="resolutionMeeting">
|
<ng-container matColumnDef="resolutionMeeting">
|
||||||
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
|
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
|
||||||
<td mat-cell *matCellDef="let transaction">
|
<td mat-cell *matCellDef="let transaction">
|
||||||
@if (transaction.type.value === 'Redeem') {
|
<!-- The transaction's own resolution wherever it has one: a redeem always carries one, and
|
||||||
{{ transaction.resolutionNumber || '—' }}
|
so does an additional-share purchase, which is authorised by its own board meeting
|
||||||
|
rather than the one the account was opened under. Only the original application falls
|
||||||
|
back to the account's resolution, having none of its own. -->
|
||||||
|
@if (transaction.resolutionNumber) {
|
||||||
|
{{ transaction.resolutionNumber }}
|
||||||
@if (transaction.meetingDate) {
|
@if (transaction.meetingDate) {
|
||||||
({{ transaction.meetingDate | dateFormat }})
|
({{ transaction.meetingDate | dateFormat }})
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<!-- Purchases are authorised by the share account's own board resolution, which is held
|
|
||||||
on the account rather than the transaction. -->
|
|
||||||
{{ shareAccountData.resolutionNumber || '—' }}
|
{{ shareAccountData.resolutionNumber || '—' }}
|
||||||
@if (shareAccountData.resolutionDate) {
|
@if (shareAccountData.resolutionDate) {
|
||||||
({{ shareAccountData.resolutionDate | dateFormat }})
|
({{ shareAccountData.resolutionDate | dateFormat }})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { isSettledShareTransaction } from 'app/shares/share-transactions.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transactions Tab Component.
|
* Transactions Tab Component.
|
||||||
@@ -78,7 +79,7 @@ export class TransactionsTabComponent implements OnInit {
|
|||||||
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||||
this.shareAccountData = data.sharesAccountData;
|
this.shareAccountData = data.sharesAccountData;
|
||||||
this.transactionsData = this.shareAccountData.purchasedShares
|
this.transactionsData = this.shareAccountData.purchasedShares
|
||||||
.filter((transaction: any) => transaction.type.value !== 'Charge Payment')
|
.filter(isSettledShareTransaction)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ export class SharesService {
|
|||||||
return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams });
|
return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a new additional-share tranche can be funded from the member's linked savings.
|
||||||
|
*
|
||||||
|
* Tranche-scoped, unlike `/sharesavingsdebit/eligibility`: that one values an account's first
|
||||||
|
* application by summing every purchase on the account, which for a further tranche would quote
|
||||||
|
* the member's whole holding history and call an already-settled account ineligible.
|
||||||
|
*
|
||||||
|
* @returns {Observable<any>} `{ eligible, reason, requestedShares, unitPrice, amount,
|
||||||
|
* savingsAccountNo, savingsBalance }`.
|
||||||
|
*/
|
||||||
|
getAdditionalSharesSavingsEligibility(accountId: string, requestedShares: number): Observable<any> {
|
||||||
|
const httpParams = new HttpParams().set('requestedShares', String(requestedShares));
|
||||||
|
return this.http.get(`/shareaccounts/${accountId}/additionalshares/savings-eligibility`, { params: httpParams });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} accountId Shares Account Id.
|
* @param {string} accountId Shares Account Id.
|
||||||
* @param {any} data Details edit payload.
|
* @param {any} data Details edit payload.
|
||||||
@@ -146,6 +161,19 @@ export class SharesService {
|
|||||||
return this.http.get(`/shareaccounts/edit/pending`);
|
return this.http.get(`/shareaccounts/edit/pending`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share accounts submitted and awaiting a checker's approval.
|
||||||
|
*
|
||||||
|
* Its own endpoint rather than `/accounts/share`, which only lists ACTIVE accounts and returns the
|
||||||
|
* whole account graph per row; this returns just the member, product, requested shares and value a
|
||||||
|
* checker decides on.
|
||||||
|
*
|
||||||
|
* @returns {Observable<any>} One row per pending account.
|
||||||
|
*/
|
||||||
|
getPendingShareAccounts(): Observable<any> {
|
||||||
|
return this.http.get('/shareaccounts/pending');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} accountId Shares Account Id.
|
* @param {string} accountId Shares Account Id.
|
||||||
* @returns {Observable<any>}
|
* @returns {Observable<any>}
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="psa-tab-content">
|
||||||
|
@if (showData) {
|
||||||
|
<div class="psa-action-bar">
|
||||||
|
<mat-form-field appearance="outline" class="psa-search-field">
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
<input
|
||||||
|
#filterInput
|
||||||
|
matInput
|
||||||
|
placeholder="Search by account number, member or product"
|
||||||
|
(keyup)="applyFilter(filterInput.value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="dataSource" class="modern-table">
|
||||||
|
<ng-container matColumnDef="accountNo">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Account No.</th>
|
||||||
|
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="clientName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Member</th>
|
||||||
|
<td mat-cell *matCellDef="let account">
|
||||||
|
{{ account.clientName }}
|
||||||
|
@if (account.officeName) {
|
||||||
|
<div class="psa-sub">{{ account.officeName }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="productName">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Product</th>
|
||||||
|
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="requestedShares">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
|
||||||
|
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="requestedAmount">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
|
||||||
|
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
|
||||||
|
{{ account.requestedAmount | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- The board resolution the subscription was authorised under. A checker approving share
|
||||||
|
capital is confirming that authority exists, so it belongs in the row. -->
|
||||||
|
<ng-container matColumnDef="resolution">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Resolution</th>
|
||||||
|
<td mat-cell *matCellDef="let account">
|
||||||
|
{{ account.resolutionNumber || '—' }}
|
||||||
|
@if (account.resolutionDate) {
|
||||||
|
<div class="psa-sub">{{ formatDate(account.resolutionDate) }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="submittedDate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Submitted</th>
|
||||||
|
<td mat-cell *matCellDef="let account">
|
||||||
|
{{ formatDate(account.submittedDate) }}
|
||||||
|
@if (account.submittedByUsername) {
|
||||||
|
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let account">
|
||||||
|
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewAccount(account)">
|
||||||
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approve(account)">
|
||||||
|
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="reject(account)">
|
||||||
|
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!showData) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||||
|
<p class="empty-title">No pending share accounts</p>
|
||||||
|
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$border: #e2e8f0;
|
||||||
|
$text-muted: #64748b;
|
||||||
|
|
||||||
|
.psa-tab-content {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-search-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-btn {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-btn-view {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-btn-approve {
|
||||||
|
border-color: #16a34a;
|
||||||
|
color: #16a34a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-btn-reject {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.psa-mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular figures so share counts and amounts line up digit for digit down their columns — a queue
|
||||||
|
is scanned, and two subscriptions of very different sizes should look different at a glance. */
|
||||||
|
.psa-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The second line in a cell — branch under the member, meeting date under the resolution number,
|
||||||
|
who submitted it under the date. Kept with the value it qualifies rather than given a column of
|
||||||
|
its own, which would make an already wide table wider for something read together anyway. */
|
||||||
|
.psa-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed $border;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 42px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: $text-muted;
|
||||||
|
max-width: 380px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import {
|
||||||
|
MatTableDataSource,
|
||||||
|
MatTable,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCellDef,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatCell,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatRowDef,
|
||||||
|
MatRow
|
||||||
|
} from '@angular/material/table';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
import { SharesService } from 'app/shares/shares.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||||
|
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||||
|
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||||
|
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker-facing list of share accounts submitted and awaiting approval.
|
||||||
|
*
|
||||||
|
* Mirrors PendingShareAccountEditsComponent's list + approve/reject pattern, but for whole accounts
|
||||||
|
* rather than edits to one. Shows what the decision turns on — the member, the product, how many
|
||||||
|
* shares they have subscribed for and what that comes to, and the board resolution behind it — so a
|
||||||
|
* checker can act from the list instead of opening every account to find out what it is.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-pending-share-accounts',
|
||||||
|
templateUrl: './pending-share-accounts.component.html',
|
||||||
|
styleUrls: ['./pending-share-accounts.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
MatTable,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCellDef,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatCell,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatRowDef,
|
||||||
|
MatRow,
|
||||||
|
MatIcon,
|
||||||
|
DecimalPipe
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PendingShareAccountsComponent {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
|
dataSource: MatTableDataSource<any>;
|
||||||
|
showData = false;
|
||||||
|
|
||||||
|
displayedColumns: string[] = [
|
||||||
|
'accountNo',
|
||||||
|
'clientName',
|
||||||
|
'productName',
|
||||||
|
'requestedShares',
|
||||||
|
'requestedAmount',
|
||||||
|
'resolution',
|
||||||
|
'submittedDate',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.data.subscribe((data: { pendingShareAccountsData: any }) => {
|
||||||
|
const items = data.pendingShareAccountsData || [];
|
||||||
|
this.showData = items.length > 0;
|
||||||
|
this.dataSource = new MatTableDataSource(items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilter(filterValue: string = '') {
|
||||||
|
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date from the API rendered readably, whether it arrives as a string or as Fineract's
|
||||||
|
* {@code [yyyy, m, d]} array.
|
||||||
|
*/
|
||||||
|
formatDate(date: any): string {
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
|
||||||
|
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the account itself, for a checker who wants more than the row shows. */
|
||||||
|
viewAccount(account: any): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/clients',
|
||||||
|
account.clientId,
|
||||||
|
'shares-accounts',
|
||||||
|
account.shareAccountId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(account: any): void {
|
||||||
|
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||||
|
data: {
|
||||||
|
heading: 'Approve Share Account',
|
||||||
|
dialogContext:
|
||||||
|
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
|
||||||
|
`${account.requestedShares} shares at ${account.requestedAmount}?`,
|
||||||
|
type: 'basic'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (response?.confirm) {
|
||||||
|
// The approval date is the business date rather than the checker's choice: this queue is for
|
||||||
|
// clearing a submission, and a date field per row would be four extra clicks to type what
|
||||||
|
// the system already knows.
|
||||||
|
const data = {
|
||||||
|
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
|
||||||
|
dateFormat: this.settingsService.dateFormat,
|
||||||
|
locale: this.settingsService.language.code
|
||||||
|
};
|
||||||
|
this.sharesService
|
||||||
|
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
|
||||||
|
.subscribe(() => this.reload());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(account: any): void {
|
||||||
|
const formfields: FormfieldBase[] = [
|
||||||
|
new InputBase({
|
||||||
|
controlName: 'rejectReason',
|
||||||
|
label: 'Reason for rejection',
|
||||||
|
value: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||||
|
data: {
|
||||||
|
title: `Reject Share Account ${account.accountNo}`,
|
||||||
|
formfields
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (response?.data) {
|
||||||
|
// The reason is recorded as a note rather than dropped: a rejected subscription is the one a
|
||||||
|
// member is most likely to come back and ask about.
|
||||||
|
const data: any = {
|
||||||
|
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
|
||||||
|
dateFormat: this.settingsService.dateFormat,
|
||||||
|
locale: this.settingsService.language.code
|
||||||
|
};
|
||||||
|
const reason = response.data.value?.rejectReason?.trim();
|
||||||
|
if (reason) {
|
||||||
|
data.note = reason;
|
||||||
|
}
|
||||||
|
this.sharesService
|
||||||
|
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
|
||||||
|
.subscribe(() => this.reload());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
const url = this.router.url;
|
||||||
|
this.router
|
||||||
|
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
|
||||||
|
.then(() => this.router.navigate([url]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,17 @@
|
|||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account Edits</span>
|
<span>Pending Account Edits</span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- New share accounts awaiting approval. Next to Pending Share Account Edits, since the two are
|
||||||
|
the share equivalents of Pending Account and Pending Account Edits. -->
|
||||||
|
<a
|
||||||
|
class="ac-tab"
|
||||||
|
routerLink="./pending-share-accounts"
|
||||||
|
routerLinkActive="ac-tab--active"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Pending Share Accounts</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-share-account-edits"
|
routerLink="./pending-share-account-edits"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Resolve } from '@angular/router';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { SharesService } from 'app/shares/shares.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the share accounts awaiting a checker's approval, for the Pending Share Accounts screen.
|
||||||
|
*
|
||||||
|
* An empty list on failure rather than a propagated error: this feeds one tab among many, and a
|
||||||
|
* failing endpoint should leave that tab empty rather than block the whole Approvals screen from
|
||||||
|
* loading.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GetPendingShareAccounts implements Resolve<any> {
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
|
||||||
|
resolve(): Observable<any> {
|
||||||
|
return this.sharesService.getPendingShareAccounts().pipe(catchError(() => of([])));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
|
|||||||
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
||||||
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
||||||
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
||||||
|
import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component';
|
||||||
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
||||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsP
|
|||||||
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
|
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
|
||||||
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
|
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
|
||||||
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
|
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
|
||||||
|
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
|
||||||
|
|
||||||
/** Tasks Routes */
|
/** Tasks Routes */
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -144,6 +146,14 @@ const routes: Routes = [
|
|||||||
path: 'pending-current-account-edits',
|
path: 'pending-current-account-edits',
|
||||||
redirectTo: 'pending-savings-account-edits'
|
redirectTo: 'pending-savings-account-edits'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pending-share-accounts',
|
||||||
|
component: PendingShareAccountsComponent,
|
||||||
|
data: { title: 'Pending Share Accounts' },
|
||||||
|
resolve: {
|
||||||
|
pendingShareAccountsData: GetPendingShareAccounts
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'pending-share-account-edits',
|
path: 'pending-share-account-edits',
|
||||||
component: PendingShareAccountEditsComponent,
|
component: PendingShareAccountEditsComponent,
|
||||||
@@ -264,7 +274,8 @@ const routes: Routes = [
|
|||||||
GetPendingSavingsProducts,
|
GetPendingSavingsProducts,
|
||||||
GetPendingSavingsAccountEdits,
|
GetPendingSavingsAccountEdits,
|
||||||
GetPendingCurrentAccountEdits,
|
GetPendingCurrentAccountEdits,
|
||||||
GetPendingShareAccountEdits
|
GetPendingShareAccountEdits,
|
||||||
|
GetPendingShareAccounts
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class TasksRoutingModule {}
|
export class TasksRoutingModule {}
|
||||||
|
|||||||
@@ -2598,6 +2598,7 @@
|
|||||||
"Payment Allocation Rule": "Payment Allocation Rule",
|
"Payment Allocation Rule": "Payment Allocation Rule",
|
||||||
"Payment Details": "Payment Details",
|
"Payment Details": "Payment Details",
|
||||||
"Payment Due At": "Payment Due At",
|
"Payment Due At": "Payment Due At",
|
||||||
|
"Payment Option": "Payment Option",
|
||||||
"Payment Type": "Payment Type",
|
"Payment Type": "Payment Type",
|
||||||
"Payment Type Id": "Payment Type Id",
|
"Payment Type Id": "Payment Type Id",
|
||||||
"Payment due as of": "Payment due as of",
|
"Payment due as of": "Payment due as of",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260825',
|
'version': '260827',
|
||||||
'hash': '7ef75c90'
|
'hash': '1273bdbe'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user