feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks
This commit is contained in:
@@ -132,6 +132,7 @@ export class CreateSharesAccountComponent {
|
||||
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
|
||||
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
|
||||
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
|
||||
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
|
||||
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
|
||||
dateFormat,
|
||||
locale
|
||||
|
||||
@@ -140,9 +140,13 @@ export class EditShareAccountDetailsComponent {
|
||||
const payload: any = {
|
||||
locale,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
savingsAccountId: terms.savingsAccountId,
|
||||
allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients
|
||||
};
|
||||
// Optional: only sent when a savings account is actually linked, so an unlinked account
|
||||
// does not post a null the update validator would reject.
|
||||
if (terms.savingsAccountId) {
|
||||
payload.savingsAccountId = terms.savingsAccountId;
|
||||
}
|
||||
if (terms.minimumActivePeriod) {
|
||||
payload.minimumActivePeriod = terms.minimumActivePeriod;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export class EditSharesAccountComponent {
|
||||
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
|
||||
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
|
||||
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
|
||||
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
|
||||
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
|
||||
dateFormat,
|
||||
locale
|
||||
|
||||
+20
-1
@@ -54,6 +54,24 @@
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48" (click)="resolutionDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="resolutionDatePicker"
|
||||
formControlName="resolutionDate"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #resolutionDatePicker></mat-datepicker>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="layout-row align-center gap-2percent margin-t responsive-column">
|
||||
@@ -68,7 +86,8 @@
|
||||
!(
|
||||
sharesAccountDetailsForm.get('productId')?.value &&
|
||||
sharesAccountDetailsForm.get('submittedDate')?.value &&
|
||||
sharesAccountDetailsForm.get('resolutionNumber')?.value
|
||||
sharesAccountDetailsForm.get('resolutionNumber')?.value &&
|
||||
sharesAccountDetailsForm.get('resolutionDate')?.value
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
+7
-1
@@ -76,7 +76,9 @@ export class SharesAccountDetailsStepComponent implements OnInit {
|
||||
this.sharesAccountTemplate.timeline.submittedOnDate &&
|
||||
new Date(this.sharesAccountTemplate.timeline.submittedOnDate),
|
||||
externalId: this.sharesAccountTemplate.externalId,
|
||||
resolutionNumber: this.sharesAccountTemplate.resolutionNumber
|
||||
resolutionNumber: this.sharesAccountTemplate.resolutionNumber,
|
||||
resolutionDate:
|
||||
this.sharesAccountTemplate.resolutionDate && new Date(this.sharesAccountTemplate.resolutionDate)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -102,6 +104,10 @@ export class SharesAccountDetailsStepComponent implements OnInit {
|
||||
resolutionNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
resolutionDate: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
+14
-3
@@ -41,6 +41,11 @@
|
||||
<span class="flex-60">{{ sharesAccount.resolutionNumber }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-fill">
|
||||
<span class="flex-40">{{ 'labels.inputs.Resolution Date' | translate }}</span>
|
||||
<span class="flex-60">{{ sharesAccount.resolutionDate | dateFormat }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
|
||||
|
||||
<mat-divider class="flex-fill"></mat-divider>
|
||||
@@ -71,9 +76,15 @@
|
||||
|
||||
<div class="flex-fill">
|
||||
<span class="flex-40">{{ 'labels.inputs.Default Savings Account' | translate }}</span>
|
||||
<span class="flex-60">{{
|
||||
sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo'
|
||||
}}</span>
|
||||
<span class="flex-60">
|
||||
@if (sharesAccount.savingsAccountId) {
|
||||
{{
|
||||
sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo'
|
||||
}}
|
||||
} @else {
|
||||
{{ 'labels.inputs.Unassigned' | translate }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (sharesAccount.minimumActivePeriod) {
|
||||
|
||||
+27
@@ -30,6 +30,9 @@
|
||||
<span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span>
|
||||
}
|
||||
|
||||
<!-- No "None" option: the server rejects a share account without a linked savings account, and
|
||||
offering the choice only produced a failure after the whole stepper was filled in. The link
|
||||
is also where an over-payment at the counter is credited back to the member. -->
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
|
||||
<mat-select formControlName="savingsAccountId" required>
|
||||
@@ -42,6 +45,12 @@
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
@if (savingsAccountsData?.length === 0) {
|
||||
<p class="sats-field-note flex-98">
|
||||
This client has no active savings account in the share product's currency. Open one first — a share account
|
||||
cannot be created without it.
|
||||
</p>
|
||||
}
|
||||
|
||||
<mat-form-field class="flex-48" (click)="applicationDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Application Date' | translate }}</mat-label>
|
||||
@@ -67,6 +76,15 @@
|
||||
|
||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Minimum Active Period' | translate }}</h4>
|
||||
|
||||
<!-- Spelled out because the field name does not say what it decides. This is the holding period
|
||||
that qualifies a member for a dividend: shares bought fewer days before the period end than
|
||||
this are skipped entirely by the dividend allocation, and the member is paid nothing on
|
||||
them. Setting it wrong is silent — nobody sees it until a declaration pays out. -->
|
||||
<p class="sats-field-note flex-98">
|
||||
Enter the minimum number of days a member must hold these shares before they qualify for a dividend. Shares held
|
||||
for fewer days than this are excluded from the dividend calculation and earn nothing for that period.
|
||||
</p>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
|
||||
<input type="number" matInput formControlName="minimumActivePeriod" />
|
||||
@@ -88,6 +106,15 @@
|
||||
|
||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Lock-in Period' | translate }}</h4>
|
||||
|
||||
<!-- Sits next to Minimum Active Period and is easily confused with it, so the difference is
|
||||
stated outright: that one decides whether a dividend is earned, this one decides whether
|
||||
shares can be sold back. Counted per purchase, not per account — a later purchase carries
|
||||
its own lock-in from its own date, which is not obvious from a single "Frequency" box. -->
|
||||
<p class="sats-field-note flex-98">
|
||||
Enter the minimum number of days a member must wait before they can redeem (sell back) these shares. This is
|
||||
counted separately from each purchase, and does not affect dividends — for that, use Minimum Active Period above.
|
||||
</p>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
|
||||
<input type="number" matInput formControlName="lockinPeriodFrequency" />
|
||||
|
||||
+11
@@ -14,3 +14,14 @@ h4 {
|
||||
.margin-t {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Red because both fields it marks fail silently — a wrong Minimum Active Period pays the member
|
||||
nothing months later, a wrong Lock-in Period only surfaces when a redemption is refused. Neither
|
||||
raises a validation error at the time. Sits under the heading and above the inputs, so it is read
|
||||
before the number is typed rather than after. */
|
||||
.sats-field-note {
|
||||
margin: -0.5em 0 1em;
|
||||
color: #b42318;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
+7
@@ -128,6 +128,13 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
|
||||
],
|
||||
unitPrice: [{ value: '', disabled: true }],
|
||||
currencyMultiple: [{ value: '', disabled: true }],
|
||||
// Required to match the server, which has always rejected a share account without a linked
|
||||
// savings account. The form allowed one through, so the failure surfaced as a validation
|
||||
// error on submit after the whole stepper had been filled in.
|
||||
//
|
||||
// It also gives an over-payment somewhere to go: a member who hands over more cash than the
|
||||
// application costs has the difference credited to this account, rather than being left in a
|
||||
// suspense GL that nobody can attribute back to them.
|
||||
savingsAccountId: [
|
||||
'',
|
||||
Validators.required
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Apply Share Advance Collection</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="intro">
|
||||
Enter the voucher number the member was given, or tick the receipts below, to settle against share account
|
||||
<b>{{ data.accountNo }}</b
|
||||
>. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share
|
||||
capital, and any entry fee is posted to fee income.
|
||||
</p>
|
||||
|
||||
<div class="voucher-lookup">
|
||||
<input
|
||||
type="text"
|
||||
class="voucher-input"
|
||||
placeholder="Voucher no. (e.g. 000123)"
|
||||
[(ngModel)]="voucherNo"
|
||||
[disabled]="lookingUp || submitting"
|
||||
(keyup.enter)="lookupVoucher()"
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!voucherNo.trim() || lookingUp || submitting"
|
||||
(click)="lookupVoucher()"
|
||||
>
|
||||
{{ lookingUp ? 'Finding…' : 'Find' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>Loading receipts…</span>
|
||||
</div>
|
||||
} @else if (receipts.length === 0) {
|
||||
<div class="empty">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
<b>No unapplied receipt for this member.</b>
|
||||
<div class="empty-hint">
|
||||
Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry GL. Once a
|
||||
manager approves it, enter the voucher number above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<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 (r of receipts; track r.id) {
|
||||
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
|
||||
<td class="ta-center">
|
||||
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
|
||||
against one application, and all of them settle it together. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(r.id)"
|
||||
(change)="toggle(r.id)"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
|
||||
<td class="ta-left">{{ r.collection_date }}</td>
|
||||
<td class="ta-right">
|
||||
<b>{{ r.amount }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (selectedIds.length > 1) {
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td></td>
|
||||
<td class="ta-left" colspan="2">{{ selectedIds.length }} receipts selected</td>
|
||||
<td class="ta-right">
|
||||
<b>{{ selectedTotal | number: '1.2-2' }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
|
||||
<div class="note">
|
||||
Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry
|
||||
fee — anything paid over that is credited to the member's savings account.
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="selectedIds.length === 0 || submitting"
|
||||
(click)="apply()"
|
||||
>
|
||||
{{ submitting ? 'Applying…' : 'Apply Collection' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$primary: #1a3a6b;
|
||||
$border: #d8e0ec;
|
||||
$muted: #6b7a90;
|
||||
$danger: #b3261e;
|
||||
|
||||
.intro {
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.voucher-lookup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 0;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
background: #f4f7fb;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: $muted;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only appears once a second receipt is ticked, when adding the rows up by eye stops being
|
||||
reliable — and the total is what decides whether the settlement is accepted. */
|
||||
tfoot .total-row {
|
||||
border-top: 2px solid #cbd5e1;
|
||||
|
||||
td {
|
||||
padding-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #1f3d7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ta-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ta-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ta-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
|
||||
/**
|
||||
* Applies a Share Advance receipt to an activated share account.
|
||||
*
|
||||
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
|
||||
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
|
||||
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-apply-collection-dialog',
|
||||
templateUrl: './apply-collection-dialog.component.html',
|
||||
styleUrls: ['./apply-collection-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class ApplyCollectionDialogComponent implements OnInit {
|
||||
private cashierService = inject(CashierService);
|
||||
|
||||
loading = true;
|
||||
submitting = false;
|
||||
errorMsg = '';
|
||||
receipts: any[] = [];
|
||||
|
||||
/**
|
||||
* Receipts chosen for this settlement, in the order they were picked.
|
||||
*
|
||||
* More than one because a member often pays an application in instalments — ₹4,500 today, the
|
||||
* ₹500 balance tomorrow — and neither receipt settles the account on its own. They are applied
|
||||
* together as a single settlement rather than forcing the member back to the counter for a fresh
|
||||
* receipt covering the whole amount.
|
||||
*/
|
||||
selectedIds: number[] = [];
|
||||
|
||||
/** Voucher number quoted by the member, as an alternative to picking from the list. */
|
||||
voucherNo = '';
|
||||
lookingUp = false;
|
||||
|
||||
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
|
||||
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cashierService
|
||||
.getUnappliedShareAdvances(this.data.clientId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, 'Could not load the member’s receipts.');
|
||||
this.loading = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
if (res) {
|
||||
this.receipts = res.unappliedCollections || [];
|
||||
if (this.receipts.length === 1) {
|
||||
this.selectedIds = [this.receipts[0].id];
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(id: number): boolean {
|
||||
return this.selectedIds.includes(id);
|
||||
}
|
||||
|
||||
toggle(id: number): void {
|
||||
this.selectedIds = this.isSelected(id) ? this.selectedIds.filter((x) => x !== id) : [
|
||||
...this.selectedIds,
|
||||
id
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the selected receipts come to.
|
||||
*
|
||||
* Shown live as receipts are ticked, because the settlement only goes through when the total
|
||||
* covers the account — checking that by adding up the rows by eye is exactly what leads to a
|
||||
* rejected submission.
|
||||
*/
|
||||
get selectedTotal(): number {
|
||||
return this.receipts.filter((r) => this.isSelected(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the voucher number the member quotes into a receipt. The lookup 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 {
|
||||
const voucher = this.voucherNo.trim();
|
||||
if (!voucher || this.lookingUp) {
|
||||
return;
|
||||
}
|
||||
this.lookingUp = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
this.cashierService
|
||||
.getShareAdvanceByVoucher(voucher, this.data.clientId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
|
||||
this.lookingUp = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.lookingUp = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
if (Number(res.client_id) !== Number(this.data.clientId)) {
|
||||
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// Show it in the table (replacing any stale copy) and add it to the selection, rather than
|
||||
// replacing what is already ticked — a looked-up voucher is usually the second half of a
|
||||
// part-payment, not a change of mind about the first.
|
||||
this.receipts = [
|
||||
res,
|
||||
...this.receipts.filter((r) => r.id !== res.id)
|
||||
];
|
||||
if (!this.isSelected(res.id)) {
|
||||
this.selectedIds = [
|
||||
...this.selectedIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this.selectedIds.length === 0 || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
// The first receipt leads on the URL; the rest ride along in the body, and the server settles
|
||||
// them as one transaction.
|
||||
const [
|
||||
leadId,
|
||||
...additionalCollectionIds
|
||||
] = this.selectedIds;
|
||||
|
||||
this.cashierService
|
||||
.applyShareAdvance(leadId, { shareAccountId: this.data.shareAccountId, additionalCollectionIds })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, 'Applying the collection failed.');
|
||||
this.submitting = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
if (res) {
|
||||
this.dialogRef.close(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Debit from Savings</h2>
|
||||
|
||||
<mat-dialog-content class="dfs-content">
|
||||
@if (loading) {
|
||||
<div class="dfs-loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>Checking the member's savings account…</span>
|
||||
</div>
|
||||
} @else if (errorMsg && !eligibility) {
|
||||
<div class="dfs-error">{{ errorMsg }}</div>
|
||||
} @else if (eligibility) {
|
||||
<!-- What the settlement costs, split the way it will be posted: share value to Share Capital,
|
||||
entry fee to fee income. -->
|
||||
<div class="dfs-breakdown">
|
||||
<div class="dfs-row">
|
||||
<span>Share value</span>
|
||||
<span>{{ eligibility.shareAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (eligibility.chargeAmount > 0) {
|
||||
<div class="dfs-row">
|
||||
<span>Entry fee</span>
|
||||
<span>{{ eligibility.chargeAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="dfs-row dfs-row--total">
|
||||
<span>To debit from savings</span>
|
||||
<span>{{ eligibility.totalRequired | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (eligibility.savingsAccountNo) {
|
||||
<div class="dfs-row dfs-row--detail">
|
||||
<span>Savings account {{ eligibility.savingsAccountNo }}</span>
|
||||
<span>Balance {{ eligibility.savingsBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!eligibility.eligible) {
|
||||
<div class="dfs-error">{{ eligibility.reason }}</div>
|
||||
} @else {
|
||||
<label class="dfs-field">
|
||||
<span>Note (optional)</span>
|
||||
<input type="text" [(ngModel)]="note" maxlength="500" placeholder="Anything to record against this request" />
|
||||
</label>
|
||||
<!-- Says plainly that submitting does not move money, so the clerk does not tell the member
|
||||
their shares are settled when a manager still has to approve it. -->
|
||||
<p class="dfs-hint">
|
||||
Submitting records a request for a manager to approve. The member's savings are debited only once it is
|
||||
approved.
|
||||
</p>
|
||||
@if (errorMsg) {
|
||||
<div class="dfs-error">{{ errorMsg }}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="loading || submitting || !eligibility?.eligible"
|
||||
(click)="submit()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.dfs-content {
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.dfs-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
color: #475569;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* What the member is being charged, laid out as label/amount pairs so the total reads down the
|
||||
column the way the posting splits it. */
|
||||
.dfs-breakdown {
|
||||
margin: 8px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dfs-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
font-size: 13.5px;
|
||||
color: #334155;
|
||||
|
||||
> span:last-child {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--total {
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&--detail {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.dfs-field {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-size: 13.5px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dfs-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dfs-error {
|
||||
margin: 8px 0 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
|
||||
/**
|
||||
* Settles a share application out of the member's own savings account.
|
||||
*
|
||||
* The cash route (Apply Collection) starts at the counter, with money the cashier has already
|
||||
* received sitting in Share Sundry against a receipt. A member who banks with the society has no
|
||||
* reason to withdraw cash and hand it straight back, so this debits their savings directly for the
|
||||
* share value plus any entry fee.
|
||||
*
|
||||
* Nothing moves when this dialog is submitted: it records a request for a manager to approve. The
|
||||
* cash route needs no checker because the cashier's receipt is already a second pair of eyes; here
|
||||
* nobody has counted anything, so the approval is the first moment the member's balance changes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-debit-from-savings-dialog',
|
||||
templateUrl: './debit-from-savings-dialog.component.html',
|
||||
styleUrls: ['./debit-from-savings-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class DebitFromSavingsDialogComponent implements OnInit {
|
||||
private cashierService = inject(CashierService);
|
||||
|
||||
loading = true;
|
||||
submitting = false;
|
||||
errorMsg = '';
|
||||
|
||||
/**
|
||||
* What the settlement would cost and whether the savings account can cover it. `eligible` false
|
||||
* carries a `reason` naming the blocker — no linked savings, already settled, short balance.
|
||||
*/
|
||||
eligibility: {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
shareAmount: number;
|
||||
chargeAmount: number;
|
||||
totalRequired: number;
|
||||
savingsAccountNo: string | null;
|
||||
savingsBalance: number | null;
|
||||
} | null = null;
|
||||
|
||||
note = '';
|
||||
|
||||
public dialogRef = inject(MatDialogRef<DebitFromSavingsDialogComponent>);
|
||||
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cashierService
|
||||
.getShareSavingsDebitEligibility(this.data.shareAccountId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.loading = false;
|
||||
if (!res) {
|
||||
this.errorMsg = 'Could not check whether this account can be settled from savings.';
|
||||
return;
|
||||
}
|
||||
this.eligibility = {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
shareAmount: Number(res.shareAmount) || 0,
|
||||
chargeAmount: Number(res.chargeAmount) || 0,
|
||||
totalRequired: Number(res.totalRequired) || 0,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.eligibility?.eligible || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
this.cashierService
|
||||
.submitShareSavingsDebit({
|
||||
shareAccountId: this.data.shareAccountId,
|
||||
note: this.note?.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(res);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || 'Could not submit the savings-debit request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,19 @@
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ 'labels.inputs.Resolution Number' | translate }}</td>
|
||||
<td>{{ sharesAccountData.resolutionNumber || ('labels.inputs.Unassigned' | translate) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ 'labels.inputs.Resolution Date' | translate }}</td>
|
||||
@if (sharesAccountData.resolutionDate) {
|
||||
<td>{{ sharesAccountData.resolutionDate | dateFormat }}</td>
|
||||
}
|
||||
@if (!sharesAccountData.resolutionDate) {
|
||||
<td>{{ 'labels.inputs.Unassigned' | translate }}</td>
|
||||
}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ 'labels.inputs.Linked Savings Account(Dividend Posting)' | translate }}</td>
|
||||
@if (sharesAccountData.savingsAccountNumber) {
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
<div class="detail-value">{{ resolutionNumber }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (resolutionDate) {
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Resolution Date</div>
|
||||
<div class="detail-value">{{ resolutionDate | dateFormat }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,64 +139,71 @@
|
||||
|
||||
<!-- Transactions Table -->
|
||||
@if (filteredTransactions.length > 0) {
|
||||
<table class="statement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-sr">#</th>
|
||||
<th class="col-date">Date</th>
|
||||
<th class="col-desc">Description</th>
|
||||
<th class="col-ref">Ref No.</th>
|
||||
<th class="col-amount text-right">Shares Purchased</th>
|
||||
<th class="col-amount text-right">Shares Redeemed</th>
|
||||
<th class="col-balance text-right">Share Balance</th>
|
||||
<th class="col-amount text-right">Amount (₹)</th>
|
||||
<th class="col-desc">Resolution No. / Meeting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (txn of filteredTransactions; track txn.id; let i = $index) {
|
||||
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
|
||||
<td class="col-sr text-center">{{ i + 1 }}</td>
|
||||
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td>
|
||||
<td class="col-desc">
|
||||
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
|
||||
</td>
|
||||
<td class="col-ref text-muted mono">{{ txn.id }}</td>
|
||||
<td class="col-amount text-right credit-amount">
|
||||
@if (!isRedeem(txn)) {
|
||||
{{ txn.numberOfShares }}
|
||||
}
|
||||
</td>
|
||||
<td class="col-amount text-right debit-amount">
|
||||
@if (isRedeem(txn)) {
|
||||
{{ txn.numberOfShares }}
|
||||
}
|
||||
</td>
|
||||
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
|
||||
<td class="col-amount text-right">
|
||||
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td class="col-desc">
|
||||
@if (isRedeem(txn)) {
|
||||
{{ txn.resolutionNumber || '—' }}
|
||||
@if (txn.meetingDate) {
|
||||
({{ txn.meetingDate | dateFormat }})
|
||||
}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<div class="statement-table-scroll">
|
||||
<table class="statement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-sr text-center">#</th>
|
||||
<th class="col-date text-left">Date</th>
|
||||
<th class="col-desc text-left">Description</th>
|
||||
<th class="col-ref text-center">Ref No.</th>
|
||||
<th class="col-amount text-right">Shares Purchased</th>
|
||||
<th class="col-amount text-right">Shares Redeemed</th>
|
||||
<th class="col-balance text-right">Share Balance</th>
|
||||
<th class="col-amount text-right">Amount (₹)</th>
|
||||
<th class="col-resolution text-left">Resolution No. / Meeting</th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
|
||||
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (txn of filteredTransactions; track txn.id; let i = $index) {
|
||||
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
|
||||
<td class="col-sr text-center">{{ i + 1 }}</td>
|
||||
<td class="col-date text-left">{{ txn.purchasedDate | dateFormat }}</td>
|
||||
<td class="col-desc text-left">
|
||||
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
|
||||
</td>
|
||||
<td class="col-ref text-center text-muted mono">{{ txn.id }}</td>
|
||||
<td class="col-amount text-right credit-amount">
|
||||
@if (!isRedeem(txn)) {
|
||||
{{ txn.numberOfShares }}
|
||||
}
|
||||
</td>
|
||||
<td class="col-amount text-right debit-amount">
|
||||
@if (isRedeem(txn)) {
|
||||
{{ txn.numberOfShares }}
|
||||
}
|
||||
</td>
|
||||
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
|
||||
<td class="col-amount text-right">
|
||||
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td class="col-resolution text-left">
|
||||
@if (isRedeem(txn)) {
|
||||
{{ txn.resolutionNumber || '—' }}
|
||||
@if (txn.meetingDate) {
|
||||
({{ txn.meetingDate | dateFormat }})
|
||||
}
|
||||
} @else {
|
||||
<!-- Purchases are authorised by the share account's own board resolution, which is
|
||||
held on the account rather than the transaction. -->
|
||||
{{ resolutionNumber || '—' }}
|
||||
@if (resolutionDate) {
|
||||
({{ resolutionDate | dateFormat }})
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
|
||||
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-data-block">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
|
||||
@@ -214,6 +214,14 @@ $row-alt: #f4f7fb;
|
||||
}
|
||||
|
||||
// ─── Transactions Table ───────────────────────────────────────────────────────
|
||||
// The columns below are fixed-width so each cell stays under its own header. On a narrow
|
||||
// viewport that total exceeds the card, so the table scrolls here rather than being clipped
|
||||
// by the wrapper's overflow:hidden.
|
||||
.statement-table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.statement-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -225,6 +233,10 @@ $row-alt: #f4f7fb;
|
||||
border-bottom: 2px solid $bank-primary;
|
||||
}
|
||||
|
||||
// Headers default to left so each one starts exactly where its cell's text starts; the
|
||||
// numeric columns opt into right alignment via .text-right below. Without this the browser
|
||||
// (and Material's table styles) can centre the heading while the cell stays left, which
|
||||
// reads as the whole column being out of position.
|
||||
thead th {
|
||||
padding: 9px 10px;
|
||||
font-size: 11px;
|
||||
@@ -233,6 +245,25 @@ $row-alt: #f4f7fb;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// Alignment helpers must win over the thead default above, for header and cell alike, so
|
||||
// a column's heading and its values always share one alignment.
|
||||
thead th,
|
||||
tbody td,
|
||||
tfoot td {
|
||||
&.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
@@ -274,20 +305,36 @@ $row-alt: #f4f7fb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Description is the only flexible column: it absorbs the leftover width so every other
|
||||
// column keeps the fixed size its header needs and stays aligned with its cells.
|
||||
.col-desc {
|
||||
min-width: 140px;
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
// Board Resolution No. / Meeting Date — wide enough to hold "00RK2 (14 August 2026)"
|
||||
// on one line so it does not crowd the right-aligned Amount column beside it.
|
||||
.col-resolution {
|
||||
width: 190px;
|
||||
white-space: nowrap;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.col-ref {
|
||||
width: 70px;
|
||||
width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Sized to the widest header label ("Shares Purchased") so the numeric cells sit directly
|
||||
// under their own heading rather than drifting toward the neighbouring column.
|
||||
.col-amount {
|
||||
width: 100px;
|
||||
width: 128px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-balance {
|
||||
width: 90px;
|
||||
width: 112px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ─── Amount colors ────────────────────────────────────────────────────────────
|
||||
@@ -335,6 +382,10 @@ $row-alt: #f4f7fb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
@@ -450,6 +501,12 @@ $row-alt: #f4f7fb;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
// On paper there is nothing to scroll — let the table lay out at full width so no
|
||||
// column is cut off at the page edge.
|
||||
.statement-table-scroll {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.bank-header {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
|
||||
@@ -47,6 +47,7 @@ export class SharesPassbookTabComponent implements OnInit {
|
||||
status = '';
|
||||
activatedOnDate: any = null;
|
||||
resolutionNumber = '';
|
||||
resolutionDate: any = null;
|
||||
savingsAccountNumber = '';
|
||||
totalApprovedShares = 0;
|
||||
currentMarketPrice = 0;
|
||||
@@ -75,6 +76,7 @@ export class SharesPassbookTabComponent implements OnInit {
|
||||
this.status = account.status?.value || '';
|
||||
this.activatedOnDate = account.timeline?.activatedDate || null;
|
||||
this.resolutionNumber = account.resolutionNumber || '';
|
||||
this.resolutionDate = account.resolutionDate || null;
|
||||
this.savingsAccountNumber = account.savingsAccountNumber || '';
|
||||
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
||||
this.currentMarketPrice = account.currentMarketPrice || 0;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Keeps the Transactions, Passbook and Charges tabs closed until the share application is settled.
|
||||
*
|
||||
* Those tabs describe money that has moved. Until the member has actually been debited and the
|
||||
* shares issued, nothing has: the purchase sits pending approval, and showing a purchase entry, a
|
||||
* passbook line and an entry fee reads as though the member has paid and holds shares when they
|
||||
* have done neither.
|
||||
*
|
||||
* Hiding the tab links alone would leave the pages reachable by typing the URL, which is exactly
|
||||
* where someone verifying an account is likely to go. Anyone who does is sent back to General.
|
||||
*
|
||||
* Judged by `collectionApplied` rather than the account's status or the purchase transaction's
|
||||
* status: an account is Approved and then Activated before anything is settled, and the purchase
|
||||
* reads Approved from the moment the account is approved. Only this marks money actually taken.
|
||||
*/
|
||||
export const settledAccountGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
const router = inject(Router);
|
||||
|
||||
// The account resolves on the parent (SharesAccountViewComponent) route; these tabs are its
|
||||
// children. Angular merges resolved data down the tree, so route.data carries it too — read from
|
||||
// the parent explicitly so this does not depend on that merge.
|
||||
const shareAccount = route.parent?.data?.['sharesAccountData'] ?? route.data?.['sharesAccountData'];
|
||||
|
||||
if (shareAccount?.collectionApplied) {
|
||||
return true;
|
||||
}
|
||||
// Redirect rather than block outright, so the officer lands somewhere useful instead of a blank
|
||||
// page with no explanation. Built from the route's own params rather than a relative path,
|
||||
// because a guard only has the snapshot and createUrlTree's relativeTo needs a live route.
|
||||
// Walk up for the ids rather than assuming a depth: both live on ancestors of this tab route
|
||||
// (shareAccountId on the ':shareAccountId' route, clientId further up under /clients).
|
||||
let node: ActivatedRouteSnapshot | null = route;
|
||||
let clientId: string | null = null;
|
||||
let accountId: string | null = null;
|
||||
while (node && !(clientId && accountId)) {
|
||||
accountId = accountId ?? node.paramMap.get('shareAccountId');
|
||||
clientId = clientId ?? node.paramMap.get('clientId');
|
||||
node = node.parent;
|
||||
}
|
||||
if (clientId && accountId) {
|
||||
return router.createUrlTree([
|
||||
'/clients',
|
||||
clientId,
|
||||
'shares-accounts',
|
||||
accountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -135,36 +135,41 @@
|
||||
>
|
||||
{{ 'labels.inputs.General' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./transactions']"
|
||||
routerLinkActive
|
||||
#transactions="routerLinkActive"
|
||||
[active]="transactions.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||
>
|
||||
{{ 'labels.inputs.Transactions' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./passbook']"
|
||||
routerLinkActive
|
||||
#passbook="routerLinkActive"
|
||||
[active]="passbook.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||
>
|
||||
Passbook
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./charges']"
|
||||
routerLinkActive
|
||||
#charges="routerLinkActive"
|
||||
[active]="charges.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTCHARGE'"
|
||||
>
|
||||
{{ 'labels.inputs.Charges' | translate }}
|
||||
</a>
|
||||
<!-- Transactions, Passbook and Charges all describe money that has moved. Until the
|
||||
application is settled nothing has been debited and no shares exist, so showing them
|
||||
would read as though the member had paid and holds shares when they have done neither. -->
|
||||
@if (isSettled) {
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./transactions']"
|
||||
routerLinkActive
|
||||
#transactions="routerLinkActive"
|
||||
[active]="transactions.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||
>
|
||||
{{ 'labels.inputs.Transactions' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./passbook']"
|
||||
routerLinkActive
|
||||
#passbook="routerLinkActive"
|
||||
[active]="passbook.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||
>
|
||||
Passbook
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./charges']"
|
||||
routerLinkActive
|
||||
#charges="routerLinkActive"
|
||||
[active]="charges.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTCHARGE'"
|
||||
>
|
||||
{{ 'labels.inputs.Charges' | translate }}
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./dividends']"
|
||||
|
||||
@@ -14,6 +14,8 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
/** Custom Dialogs */
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
|
||||
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
|
||||
import { DebitFromSavingsDialogComponent } from './debit-from-savings-dialog/debit-from-savings-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { SharesService } from '../shares.service';
|
||||
@@ -182,6 +184,12 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
case 'Edit Details':
|
||||
this.router.navigate(['edit-details'], { relativeTo: this.route });
|
||||
break;
|
||||
case 'Apply Collection':
|
||||
this.applyCollection();
|
||||
break;
|
||||
case 'Debit from Savings':
|
||||
this.debitFromSavings();
|
||||
break;
|
||||
case 'Share Certificate':
|
||||
this.viewShareCertificate();
|
||||
break;
|
||||
@@ -191,6 +199,70 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles the member's Share Advance receipt against this account: the cash moves out of Share
|
||||
* Sundry into share capital, and the purchase entry becomes visible in the transactions and
|
||||
* passbook views.
|
||||
*/
|
||||
private applyCollection() {
|
||||
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
|
||||
width: '560px',
|
||||
data: {
|
||||
shareAccountId: this.sharesAccountData.id,
|
||||
clientId: this.sharesAccountData.clientId,
|
||||
accountNo: this.sharesAccountData.accountNo
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result) {
|
||||
// Account data comes from a route resolver, so re-running the route is what refreshes the
|
||||
// newly revealed transaction and the now-paid charges.
|
||||
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
this.sharesAccountData.clientId,
|
||||
'shares-accounts',
|
||||
this.sharesAccountData.id
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member's money has actually been taken and the shares issued.
|
||||
*
|
||||
* Until then the account is only an application: nothing has been debited and the member holds
|
||||
* nothing. The Transactions, Passbook and Charges tabs all describe money that has moved, so
|
||||
* showing them at that point reads as though the member has paid and holds shares when they have
|
||||
* done neither.
|
||||
*
|
||||
* Neither the account's status nor the purchase transaction's status can stand in for this: an
|
||||
* account goes Approved then Activated before anything is settled, and the purchase reads
|
||||
* Approved from the moment the account is approved. Only `collectionApplied` marks the point at
|
||||
* which the money was actually taken — by cash collection or by debiting savings.
|
||||
*/
|
||||
get isSettled(): boolean {
|
||||
return !!this.sharesAccountData?.collectionApplied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles this account out of the member's linked savings instead of cash at the counter.
|
||||
*
|
||||
* Only records a request — a manager's approval is what debits the member and issues the shares —
|
||||
* so unlike Apply Collection there is nothing to refresh here until that approval happens.
|
||||
*/
|
||||
private debitFromSavings() {
|
||||
this.dialog.open(DebitFromSavingsDialogComponent, {
|
||||
width: '560px',
|
||||
data: {
|
||||
shareAccountId: this.sharesAccountData.id,
|
||||
clientId: this.sharesAccountData.clientId,
|
||||
accountNo: this.sharesAccountData.accountNo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes Shares Account.
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,21 @@ export class SharesButtonsConfiguration {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
this.buttonsArray = [
|
||||
{
|
||||
// Settles the member's Share Advance receipt: moves the money out of Share Sundry
|
||||
// into share capital and reveals the purchase in the transactions/passbook.
|
||||
name: 'Apply Collection',
|
||||
icon: 'hand-holding-usd',
|
||||
taskPermissionName: 'CREATE_JOURNALENTRY'
|
||||
},
|
||||
{
|
||||
// The same settlement for a member who banks here: debits their linked savings for the
|
||||
// share value plus entry fee instead of taking cash at the counter. Raises a request
|
||||
// rather than posting, because no cashier has counted anything to check it against.
|
||||
name: 'Debit from Savings',
|
||||
icon: 'money-check-alt',
|
||||
taskPermissionName: 'CREATE_JOURNALENTRY'
|
||||
},
|
||||
{
|
||||
name: 'Apply Additional Shares',
|
||||
icon: 'arrow-right',
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
({{ transaction.meetingDate | dateFormat }})
|
||||
}
|
||||
} @else {
|
||||
—
|
||||
<!-- Purchases are authorised by the share account's own board resolution, which is held
|
||||
on the account rather than the transaction. -->
|
||||
{{ shareAccountData.resolutionNumber || '—' }}
|
||||
@if (shareAccountData.resolutionDate) {
|
||||
({{ shareAccountData.resolutionDate | dateFormat }})
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SharesAccountTemplateResolver } from './common-resolvers/shares-account
|
||||
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
||||
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
||||
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
|
||||
import { settledAccountGuard } from './shares-account-view/settled-account.guard';
|
||||
|
||||
/** Shares Routes */
|
||||
const routes: Routes = [
|
||||
@@ -71,19 +72,25 @@ const routes: Routes = [
|
||||
sharesAccountData: SharesAccountViewResolver
|
||||
}
|
||||
},
|
||||
// Guarded as well as hidden from the tab bar: these three describe money that has
|
||||
// moved, and until the application is settled none has. Hiding the links alone would
|
||||
// leave the pages reachable by typing the URL.
|
||||
{
|
||||
path: 'transactions',
|
||||
component: TransactionsTabComponent,
|
||||
canActivate: [settledAccountGuard],
|
||||
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'passbook',
|
||||
component: SharesPassbookTabComponent,
|
||||
canActivate: [settledAccountGuard],
|
||||
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'charges',
|
||||
component: ChargesTabComponent,
|
||||
canActivate: [settledAccountGuard],
|
||||
data: { title: 'Shares Account Charges', breadcrumb: 'Charges', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user