feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
@@ -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
@@ -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
)
"
>
@@ -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
]
});
}
@@ -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) {
@@ -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" />
@@ -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;
}
@@ -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
@@ -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 &rsaquo; 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>
@@ -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;
}
@@ -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
);
}
}
@@ -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>
@@ -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;
}
@@ -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 (&#8377;)</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 (&#8377;)</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>
+7
View File
@@ -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 }
},
{