DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes

This commit is contained in:
tarunmalviya
2026-10-01 13:08:52 +05:30
parent d47e0860f6
commit 885b39d970
48 changed files with 5878 additions and 374 deletions
@@ -17,6 +17,8 @@ import { DueForClosureComponent } from '../deposits/party-master/due-for-closure
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -71,6 +73,19 @@ const routes: Routes = [
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
+29 -5
View File
@@ -883,7 +883,11 @@
(click)="subLedgerOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Suit File Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('Locker Entry')" (click)="subLedgerOpen = false"
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Locker Entry')"
routerLink="/locker"
(click)="subLedgerOpen = false"
><fa-icon icon="lock" class="gl-icon"></fa-icon> Locker Entry</a
>
<a
@@ -1176,7 +1180,15 @@
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Party Master
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-accounts']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> DDS Accounts
</button>
</div>
<div class="deposit-menu-group">
@@ -1184,7 +1196,12 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-collection']" (click)="depositsMenu.closed.emit()">
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-collection']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1199,7 +1216,12 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds-reports']" (click)="depositsMenu.closed.emit()">
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-reports']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
@@ -1399,7 +1421,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item routerLink="/locker" tabindex="0">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker
</button>
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
@@ -0,0 +1,381 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text">
<h2>Apply DDS Receipt</h2>
<p>
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
@if (data.glCode) {
· GL <strong class="mono">{{ data.glCode }}</strong>
}
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading entries and receipts…</span>
</div>
} @else {
<!-- What the picked vouchers have to cover, and whether they do. -->
<div class="sc-card">
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
<div class="sc-left">
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
<span class="sc-sub"
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
>
</div>
<div class="sc-right">
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
</div>
</div>
<div
class="sc-row sc-sep"
[class.sc-ok]="canApply"
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
>
<div class="sc-left">
<span class="sc-label">Vouchers picked</span>
<span class="sc-sub"
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="canApply"
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
>
{{ received | number: '1.2-2' }}
</span>
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
} @else if (canApply) {
<span class="sc-tag sc-tag-ok">✓ Matches</span>
}
</div>
</div>
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total to Apply</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
</div>
</header>
@if (days.length) {
<ul class="options">
@for (day of days; track day.date) {
<li class="option" (click)="toggleOpen(day, $event)">
<div class="option-main">
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
<span class="option-meta"
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
>
</div>
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
<button
type="button"
class="option-info"
(click)="toggleOpen(day, $event)"
aria-label="Show the day's lines"
>
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</li>
@if (openDay === day.date) {
<li class="day-lines">
@if (day.entries.length > 8) {
<input
class="day-search"
type="text"
[(ngModel)]="daySearch"
placeholder="Search party code or name"
autocomplete="off"
/>
}
<div class="day-scroll">
<table class="entries">
<tbody>
@for (entry of visibleEntries(day); track entry.id) {
<tr>
<td class="entry-code">{{ entry.partyCode }}</td>
<td class="entry-name">{{ entry.partyName }}</td>
<td>
{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</td>
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="entry-name">No line matches.</td>
</tr>
}
</tbody>
</table>
</div>
</li>
}
}
</ul>
} @else {
<p class="slot-empty">
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
on the screen to the day the entries were written.
</p>
}
</section>
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>DDS Cash Receipts</h3>
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (available.length) {
<ul class="options">
@for (option of available; track option.id) {
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
<mat-icon class="option-check">{{
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
}}</mat-icon>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
<mat-icon>receipt_long</mat-icon>
</button>
</li>
}
</ul>
} @else {
<p class="slot-empty">
No DDS receipt is waiting on this agent's GL account. Collect the cash at
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
or find an earlier voucher by number.
</p>
}
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
<p class="slot-note note-bad">
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
required | number: '1.2-2'
}}) exactly, so every customer is credited what the agent wrote down.
</p>
}
</section>
@if (errorMsg && !searchOpen) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Receipt</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a DDS receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/24092026/0001"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.txnDate }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.managerName) {
<div>
<dt>Approved By</dt>
<dd>{{ viewing.managerName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track entry.id) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
</td>
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -0,0 +1,95 @@
/**
* 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/.
*/
// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
@use '../../../shares/share-dialog.shared' as *;
:host {
display: block;
width: 100%;
}
// The agent's outstanding lines - read only, what the receipt has to cover.
.entries {
width: 100%;
border-collapse: collapse;
font-size: 13px;
td {
padding: 7px 4px;
border-bottom: 1px solid #eef2f7;
}
tr:last-child td {
border-bottom: none;
}
.entry-date {
color: #64748b;
white-space: nowrap;
}
.entry-code {
font-weight: 600;
white-space: nowrap;
}
.entry-name {
color: #475569;
}
.num {
text-align: right;
white-space: nowrap;
}
}
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
.option-check {
color: #94a3b8;
flex-shrink: 0;
}
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #2563eb;
}
}
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
// dialog a thousand lines tall.
.day-lines {
list-style: none;
padding: 6px 10px 10px 42px;
}
.day-search {
width: 100%;
margin-bottom: 6px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font: inherit;
font-size: 13px;
}
.day-scroll {
max-height: 240px;
overflow-y: auto;
}
.field-readonly {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
@@ -0,0 +1,351 @@
/**
* 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 } 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 { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
/** One day of an agent's outstanding entries, as the dialog lists them. */
export interface EntryDay {
date: string;
entries: any[];
total: number;
}
/**
* Applies the cashier's DDS receipts to an agent's outstanding collections.
*
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
* settled against the application. The same screen as Apply Additional Shares.
*
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
* because what the agent wrote down is settled whole, and an agent who comes in after several days
* brings several days' cash. The vouchers are ticked, since a day's
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
* money nobody can place.
*
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
* customers is a thousand lines, and the day's total is what the counter is checking.
*/
@Component({
selector: 'mifosx-apply-dds-receipt-dialog',
templateUrl: './apply-dds-receipt-dialog.component.html',
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyDdsReceiptDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
public data = inject<{
agentId: number;
agentCode: string;
agentName: string;
glCode: string | null;
entryDate: string;
}>(MAT_DIALOG_DATA);
loadingEntries = true;
loadingReceipts = true;
submitting = false;
errorMsg = '';
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
days: EntryDay[] = [];
/** DDS receipts on the agent's GL account, approved and not yet applied. */
available: any[] = [];
/**
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
* is picked for them even when only one is listed.
*/
pickedDays = new Set<string>();
pickedReceipts = new Set<number>();
/** The day whose lines are open, and the search within it. */
openDay: string | null = null;
daySearch = '';
get loading(): boolean {
return this.loadingEntries || this.loadingReceipts;
}
ngOnInit(): void {
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
// several days arrives in one visit, and all of it is settled together.
this.organizationService
.getAgentPendingCollections(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
return of(null);
})
)
.subscribe((res: any) => {
this.days = groupByDay(
(res?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
)
);
this.pickedDays = new Set(this.days.map((day) => day.date));
this.loadingEntries = false;
});
this.organizationService
.getAgentCashReceipts(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
return of([]);
})
)
.subscribe((rows: any) => {
this.available = Array.isArray(rows) ? rows : [];
this.loadingReceipts = false;
});
}
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
toggleReceipt(receipt: any): void {
if (this.submitting) {
return;
}
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
}
toggleOpen(day: EntryDay, event: Event): void {
event.stopPropagation();
this.openDay = this.openDay === day.date ? null : day.date;
this.daySearch = '';
}
/** The open day's lines, narrowed by party code or name. */
visibleEntries(day: EntryDay): any[] {
const term = this.daySearch.trim().toLowerCase();
if (!term) {
return day.entries;
}
return day.entries.filter(
(entry: any) =>
String(entry.partyCode || '')
.toLowerCase()
.includes(term) ||
String(entry.partyName || '')
.toLowerCase()
.includes(term)
);
}
// ── What has to be covered ──────────────────────────────────────────────────────────────────
get entryCount(): number {
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
}
get outstandingTotal(): number {
return this.days.reduce((sum, day) => sum + day.total, 0);
}
get pickedEntryCount(): number {
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
}
get required(): number {
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
}
get received(): number {
return round2(
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
);
}
/** Vouchers less entries: below zero they are short, above zero they are over. */
get difference(): number {
return round2(this.received - this.required);
}
get canApply(): boolean {
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
}
glLabel(option: any): string {
return [
option?.glCode,
option?.glName
]
.filter(Boolean)
.join(' — ');
}
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searching = false;
searchOpen = false;
searchVoucherNo = '';
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
/**
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
* DDS, not approved yet, or already applied - rather than simply not finding it.
*/
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
if (!voucher || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.organizationService
.getAgentCashReceipts(this.data.agentId, voucher)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
return of(null);
})
)
.subscribe((rows: any) => {
this.searching = false;
if (rows === null) {
return;
}
const found = (Array.isArray(rows) ? rows : [])[0];
if (!found) {
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
} else if (found.entryType !== 'DDS') {
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
} else if (found.status !== 'APPROVED') {
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
} else if (found.applied) {
this.errorMsg = `Voucher ${voucher} has already been applied.`;
} else {
this.available = [
found,
...this.available.filter((entry) => entry.id !== found.id)
];
this.pickedReceipts.add(found.id);
this.searchOpen = false;
}
});
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: any = null;
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: any, event: Event): void {
event.stopPropagation();
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.organizationService
.getCashReceiptGlEntries(option.id)
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (!this.canApply) {
return;
}
this.submitting = true;
this.errorMsg = '';
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
const entries = this.pickedEntryCount;
const amount = this.required;
this.organizationService
.settleAgentCollections({
agentId: Number(this.data.agentId),
cashierDepositRequestIds: Array.from(this.pickedReceipts),
entryDates: Array.from(this.pickedDays),
locale: this.settingsService.language.code
})
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close({ vouchers, entries, amount, response: res });
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.developerMessage ??
fallback
);
}
}
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
export function groupByDay(entries: any[]): EntryDay[] {
const byDay = new Map<string, EntryDay>();
for (const entry of entries) {
const date = String(entry.entryDate);
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
day.entries.push(entry);
day.total = round2(day.total + Number(entry.amount || 0));
byDay.set(date, day);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -79,8 +79,8 @@
</dl>
} @else {
<p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what
the balance becomes.
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
@@ -121,7 +121,7 @@
required
[matDatepicker]="txnDatePicker"
formControlName="transactionDate"
(dateChange)="loadPendingEntries()"
(dateChange)="loadPendingEntries(); loadAgentMoney()"
/>
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
@@ -132,7 +132,10 @@
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
<mat-option [value]="type.label" [disabled]="!type.supported">
{{ type.label }}@if (!type.supported) {<span class="dte-soon"> — not available yet</span>}
{{ type.label }}
@if (!type.supported) {
<span class="dte-soon"> — not available yet</span>
}
</mat-option>
}
</mat-select>
@@ -151,7 +154,7 @@
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections to settle</h3>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
@@ -160,15 +163,25 @@
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">
{{ agentInfo.name }} has written nothing down for this date. Nothing to settle.
</p>
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode">{{ entry.transactionMode }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
@@ -181,49 +194,145 @@
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected">
Take <strong>{{ pendingTotal | number: '1.2-2' }}</strong> from {{ agentInfo.name }}, then post.
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
@if (isAgentTran) {
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent Settlement</h3>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
<p class="dte-info-panel__hint">
Cash the agent hands over to the branch, deducted from the agent's collected balance shown above.
</p>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput required type="number" formControlName="settlementAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>TRTP</mat-label>
<mat-select required formControlName="settlementMode">
@for (mode of transactionModes; track mode) {
<mat-option [value]="mode">{{ mode }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>AC Type</mat-label>
<input matInput formControlName="settlementAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>A/C No.</mat-label>
<input matInput formControlName="settlementAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="settlementDescription" />
</mat-form-field>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
Master.
</p>
} @else {
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
<p class="dte-expected">
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
right agent.
</p>
}
@if (agentWaiting > 0) {
<p class="dte-expected dte-expected--note">
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@@ -236,28 +345,80 @@
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input matInput formControlName="creditorPartyCode" />
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" />
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" />
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input matInput formControlName="debitPartyCode" />
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" />
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" />
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
@@ -287,17 +448,20 @@
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<!-- On a Party Tran there is nothing to save until the agent has written something down, so
the button says what it will do and stays out of reach until it can do it. -->
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || (isPartyTran && pendingEntries.length === 0)"
(click)="save()"
>
{{ isPartyTran ? 'Confirm & Post' : 'Save' }}
</button>
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
@if (!isPartyTran && !isAgentTran) {
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || transferToItself"
(click)="save()"
>
Save
</button>
}
</div>
</form>
</div>
@@ -160,13 +160,14 @@ $primary: #0f766e;
.dte-collection-row {
display: grid;
grid-template-columns:
minmax(0, 2.2fr) /* Party */
minmax(0, 1fr) /* TRTP */
minmax(0, 1fr) /* AC Type */
minmax(0, 1.4fr) /* A/C No. */
minmax(0, 1fr) /* Amount */
minmax(0, 1.6fr) /* Description */
auto; /* remove */
minmax(0, 2.2fr) /* Party */
minmax(0, 1fr) /* TRTP */
minmax(0, 1fr) /* AC Type */
minmax(0, 1.4fr) /* A/C No. */
minmax(0, 1fr) /* Amount */
minmax(0, 1.6fr) /* Description */
auto; /* remove */
gap: 8px 12px;
align-items: start;
padding: 10px 0;
@@ -275,7 +276,6 @@ $primary: #0f766e;
color: rgb(0 0 0 / 45%);
}
/*
* Marks a party this agent has already collected from today, on the row where it is picked.
*
@@ -334,7 +334,6 @@ $primary: #0f766e;
font-weight: 500;
}
/*
The agent's written-down round, as the counter reads it.
@@ -347,12 +346,19 @@ $primary: #0f766e;
align-items: baseline;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
&__date {
min-width: 96px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
&__code {
font-weight: 600;
min-width: 84px;
@@ -393,3 +399,90 @@ $primary: #0f766e;
font-variant-numeric: tabular-nums;
}
}
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
.dte-expected--note {
font-size: 13px;
opacity: 0.65;
margin-top: 4px;
}
.dte-apply-receipt {
margin-left: 8px;
}
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
.dte-money {
display: flex;
gap: 12px;
margin: 8px 0 12px;
flex-wrap: wrap;
&__box {
flex: 1;
min-width: 160px;
padding: 10px 14px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-variant-numeric: tabular-nums;
strong {
font-size: 18px;
}
&--due {
border-color: rgb(198 40 40 / 40%);
background: rgb(198 40 40 / 4%);
}
}
&__label {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.dte-subhead {
margin: 16px 0 4px;
font-size: 14px;
font-weight: 600;
}
.dte-pending-row--day {
cursor: pointer;
}
.dte-day-lines {
margin-left: 24px;
max-height: 280px;
overflow-y: auto;
border-left: 3px solid rgb(25 118 210 / 30%);
padding-left: 10px;
}
.dte-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgb(237 108 2 / 12%);
color: #b45309;
&--ok {
background: rgb(46 125 50 / 12%);
color: #2e7d32;
}
}
.dte-list-arrow {
cursor: pointer;
}
.dte-same-party {
color: #c62828;
font-weight: 600;
}
@@ -12,17 +12,25 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } fr
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatIconModule } from '@angular/material/icon';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/** Custom Components */
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
import {
ApplyDdsReceiptDialogComponent,
EntryDay,
groupByDay
} from './apply-dds-receipt-dialog/apply-dds-receipt-dialog.component';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Daily Transaction Entry component.
@@ -45,7 +53,11 @@ import { Dates } from 'app/core/utils/dates';
selector: 'mifosx-daily-transaction-entry',
templateUrl: './daily-transaction-entry.component.html',
styleUrls: ['./daily-transaction-entry.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
imports: [
...STANDALONE_SHARED_IMPORTS,
MatAutocompleteModule,
MatIconModule
]
})
export class DailyTransactionEntryComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@@ -301,9 +313,11 @@ export class DailyTransactionEntryComponent implements OnInit {
row.get('amount')?.setValue(picked.amount);
row.get('advanceDays')?.setValue(picked.advanceDays ?? '');
row.get('arrearsDays')?.setValue(picked.arrearsDays ?? '');
row.get('description')?.setValue(
`${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}`
);
row
.get('description')
?.setValue(
`${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}`
);
this.recalculateRowBalance(this.collections.length - 1);
});
}
@@ -373,10 +387,11 @@ export class DailyTransactionEntryComponent implements OnInit {
}
/**
* Reads what this agent has outstanding for the date on the form.
* Reads everything this agent has outstanding up to the date on the form, customer by customer.
*
* Asked again whenever the agent or the date changes, because both pick out a different day's
* round - and a list left over from the previous choice is a list the clerk might settle.
* Not only that day's: what was written on earlier days and not yet handed in is still owed, and a
* clerk checking the round should see it in one list rather than open every date in turn. Each line
* carries its own date. Asked again whenever the agent or the date changes.
*/
loadPendingEntries(): void {
const agentId = this.entryForm.get('agentId')?.value;
@@ -387,10 +402,12 @@ export class DailyTransactionEntryComponent implements OnInit {
}
this.pendingLoading = true;
const on = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentCollectionDay(agentId, on).subscribe({
const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentPendingCollections(agentId).subscribe({
next: (summary: any) => {
this.pendingEntries = (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING');
this.pendingEntries = (summary?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo
);
this.pendingLoading = false;
},
error: () => {
@@ -400,6 +417,138 @@ export class DailyTransactionEntryComponent implements OnInit {
});
}
/** The picked agent's own GL account code - where the cashier receipts their cash. */
agentGlCode: string | null = null;
agentGlName: string | null = null;
// ── Agent Tran: the agent's money, owed and received ──────────────────────────────────────
//
// The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran
// shows is where that stands: what the agent owes on everything they wrote down, what the cashier has
// taken in against it, and what is left - and from here the receipts are applied to the customers.
/** What the agent still has outstanding up to the date on the form, a day at a time. */
agentDays: EntryDay[] = [];
/** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */
agentReceipts: any[] = [];
agentMoneyLoading = false;
/** The day whose lines are open in the Agent Tran list. */
agentOpenDay: string | null = null;
get agentOwed(): number {
return this.agentDays.reduce((sum, day) => sum + day.total, 0);
}
get agentReceived(): number {
return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
get agentWaiting(): number {
return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0);
}
/**
* What is still to be taken from the agent for this date - never below nothing. The receipts are
* not tied to a date (cash often reaches the counter the day after the round), so more received than
* this date's entries is not a debt the other way; it is cash for another day, said so separately.
*/
get agentBalance(): number {
return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100);
}
/** Received cash beyond this date's entries - most likely for another day's. */
get agentReceivedBeyondDay(): number {
return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100);
}
loadAgentMoney(): void {
const agentId = this.entryForm.get('agentId')?.value;
const date = this.entryForm.get('transactionDate')?.value;
this.agentOpenDay = null;
if (!agentId || !date) {
this.agentDays = [];
this.agentReceipts = [];
return;
}
this.agentMoneyLoading = true;
// Everything still outstanding up to the date on the screen, whatever day it was written: an agent
// who comes in after three days brings three days' cash, and the counter has to see all of it to
// know what to take.
const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd');
this.organizationService.getAgentPendingCollections(agentId).subscribe({
next: (summary: any) => {
this.agentDays = groupByDay(
(summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo)
);
this.agentMoneyLoading = false;
},
error: () => {
this.agentDays = [];
this.agentMoneyLoading = false;
}
});
this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({
next: (rows: any[]) => (this.agentReceipts = rows || []),
error: () => (this.agentReceipts = [])
});
}
toggleAgentDay(date: string): void {
this.agentOpenDay = this.agentOpenDay === date ? null : date;
}
/**
* Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the
* agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays
* what it is - what to take from the agent - and the applying is done in one deliberate place.
*/
openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
return;
}
const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog
.open(ApplyDdsReceiptDialogComponent, {
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
agentId,
agentCode: picked?.agentCode || '',
agentName: this.agentInfo.name || picked?.agentNameId || '',
glCode: this.agentGlCode,
// The day chosen on the screen: its entries are what the receipts settle.
entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd')
}
})
.afterClosed()
.subscribe((result: any) => {
if (!result) {
return;
}
const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 });
const vouchers: string[] = result.vouchers || [];
if (heldForApproval(result.response)) {
announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService);
this.loadPendingEntries();
return;
}
this.alertService.alert({
type: 'Success',
message:
`${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` +
`${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}`
});
this.loadPendingEntries();
this.loadTodaysEntries();
this.loadAgentMoney();
});
}
get isPartyTran(): boolean {
return this.entryForm.get('transactionType').value === 'Party Tran';
}
@@ -487,6 +636,113 @@ export class DailyTransactionEntryComponent implements OnInit {
next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)),
error: () => (this.agents = [])
});
// Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked
// fills in whose account it is and what it holds, so the clerk checks the transfer against both.
this.organizationService.getParties().subscribe({
next: (parties: any) => {
const all = Array.isArray(parties) ? parties : parties?.pageItems || [];
this.openParties = all.filter((party: any) => party.status === 'ACTIVE');
this.fillTransferParty('creditor');
this.fillTransferParty('debit');
},
error: () => (this.openParties = [])
});
this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor'));
this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit'));
}
/** Open DDS accounts, for the two sides of a Withdraw Deposite. */
openParties: any[] = [];
/**
* The open accounts either side can be picked from. The server moves money only between two
* accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered.
*/
private get transferableParties(): any[] {
const agentId = Number(this.entryForm.get('agentId')?.value) || null;
return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties;
}
/** The open accounts matching what has been typed on one side - by code, name or account number, any case. */
transferPartyOptions(side: 'creditor' | 'debit'): any[] {
const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '')
.trim()
.toLowerCase();
const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '')
.trim()
.toUpperCase();
// Once an account is picked the whole list comes back, so another can be chosen without clearing the box.
const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed);
return this.transferableParties
.filter((party: any) => String(party.partyCode).toUpperCase() !== other)
.filter(
(party: any) =>
!typed ||
picked ||
String(party.partyCode).toLowerCase().includes(typed) ||
String(party.partyName || '')
.toLowerCase()
.includes(typed) ||
String(party.accountNo || '')
.toLowerCase()
.includes(typed)
)
.slice(0, 50);
}
/** True when the same account has been put on both sides. */
get transferToItself(): boolean {
const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '')
.trim()
.toUpperCase();
const debit = String(this.entryForm.get('debitPartyCode')?.value || '')
.trim()
.toUpperCase();
return this.isWithdrawDeposite && !!creditor && creditor === debit;
}
/**
* When the box is left with a name or part of one typed in and only one account matches it, that
* account is taken - so typing "amit" and moving on picks Amit's account without opening the list.
*/
settleTransferParty(side: 'creditor' | 'debit'): void {
const control = this.entryForm.get(side + 'PartyCode');
const typed = String(control?.value || '').trim();
if (!typed || this.partyByCode(typed)) {
return;
}
const matches = this.transferPartyOptions(side);
if (matches.length === 1) {
control?.setValue(matches[0].partyCode);
}
}
private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' {
return side === 'creditor' ? 'debit' : 'creditor';
}
private partyByCode(code: string): any {
const wanted = String(code || '')
.trim()
.toUpperCase();
return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined;
}
/**
* Fills a side's name and balance from the code on it, and clears them when it matches no account.
* The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002.
*/
private fillTransferParty(side: 'creditor' | 'debit'): void {
const control = this.entryForm.get(side + 'PartyCode');
const party = this.partyByCode(control?.value);
if (party && control?.value !== party.partyCode) {
control?.setValue(party.partyCode, { emitEvent: false });
}
this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false });
this.entryForm
.get(side + 'PartyBalance')
?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false });
}
/**
@@ -527,6 +783,9 @@ export class DailyTransactionEntryComponent implements OnInit {
if (!agentId) {
this.agentParties = [];
this.pendingEntries = [];
this.agentGlCode = null;
this.agentDays = [];
this.agentReceipts = [];
return;
}
@@ -542,10 +801,20 @@ export class DailyTransactionEntryComponent implements OnInit {
guarantor2: guarantors[1]?.guarantorName || '',
currentBalance: agent.agentCurrentBalance || 0
};
this.agentGlCode = agent.glAccountCode || null;
this.agentGlName = agent.glAccountName || null;
},
error: () => {
this.agentLookupFailed = true;
this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 };
this.agentInfo = {
name: '',
address: '',
city: '',
accountNo: '',
guarantor1: '',
guarantor2: '',
currentBalance: 0
};
}
});
@@ -559,6 +828,7 @@ export class DailyTransactionEntryComponent implements OnInit {
this.loadTodaysEntries();
this.loadPendingEntries();
this.loadAgentMoney();
}
/** This agent's collections already recorded for the date on the screen. */
@@ -574,8 +844,8 @@ export class DailyTransactionEntryComponent implements OnInit {
next: (transactions: any[]) => {
this.todaysEntries = (transactions || []).filter(
(transaction: any) =>
transaction.transactionType === 'PARTY_TRAN'
&& this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate
transaction.transactionType === 'PARTY_TRAN' &&
this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate
);
},
error: () => (this.todaysEntries = [])
@@ -672,36 +942,18 @@ export class DailyTransactionEntryComponent implements OnInit {
* warn about.
*/
save(): void {
if (!this.isPartyTran) {
this.postTransaction();
// A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so
// the cash can be counted against them; taking that cash in is done where the branch takes cash
// in, and two ways to credit the same customers is one way too many. The button is not rendered
// for this type either - this is the same rule said again where it cannot be bypassed.
// Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's
// counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it
// stands and applies the cashier's receipts.
if (this.isPartyTran || this.isAgentTran) {
return;
}
if (this.pendingEntries.length === 0) {
return;
}
const money = Number(this.pendingTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 });
const count = this.pendingEntries.length;
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: 'Settle these collections',
dialogContext:
`Take ${money} from ${this.agentInfo.name || 'the agent'} and post `
+ `${count} ${count === 1 ? 'collection' : 'collections'}? `
+ 'Every customer on the list is credited and the cash is recorded against the agent. '
+ 'This cannot be undone.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.postTransaction();
}
});
this.postTransaction();
}
private postTransaction(): void {
@@ -743,10 +995,17 @@ export class DailyTransactionEntryComponent implements OnInit {
// What was just posted, captured before the form is cleared, so the confirmation can name it.
const posted = this.postedSummary(raw);
this.organizationService.createPartyTransaction(payload).subscribe(() => {
this.organizationService.createPartyTransaction(payload).subscribe((response: any) => {
// On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk
// who looks away reasonably assumes it did not go through and posts the whole round again.
this.alertService.alert({ type: 'Success', message: posted });
//
// With maker-checker on, the request comes back with a command id and nothing done: it is
// waiting for a checker, and saying it was posted would be telling the clerk the money moved.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: 'Success',
message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted
});
this.collections.clear();
this.partyInfo = null;
@@ -794,8 +1053,18 @@ export class DailyTransactionEntryComponent implements OnInit {
dateFormat: 'yyyy-MM-dd',
locale: this.settingsService.language.code
})
.subscribe(() => {
.subscribe((response: any) => {
const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 });
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`,
this.organizationService,
this.alertService
);
this.loadPendingEntries();
return;
}
this.alertService.alert({
type: 'Success',
message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money
@@ -0,0 +1,557 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dav-page">
@if (isLoading) {
<div class="dav-card dav-state">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading account…</span>
</div>
} @else if (!account) {
<div class="dav-card dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>DDS account not found</span>
<button class="dav-btn" (click)="back()"><fa-icon icon="arrow-left"></fa-icon> DDS Accounts</button>
</div>
} @else {
<!-- Banner, as the savings account page opens -->
<div class="dav-banner">
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
<span class="dav-banner__label">Account Number</span>
<span class="dav-banner__value dav-mono">{{ account.accountNo }}</span>
<span class="dav-banner__label">Client Name</span>
<span class="dav-banner__value">{{ account.clientName || account.partyName }}</span>
</div>
</div>
<div class="dav-banner__boxes">
<div class="dav-box">
<span class="dav-box__label">Current Balance</span>
<span class="dav-box__value">₹{{ account.currentBalance || 0 | formatNumber }}</span>
</div>
<div class="dav-box">
<span class="dav-box__label">Maturity Amount</span>
<span class="dav-box__value">₹{{ account.maturityAmount || 0 | formatNumber }}</span>
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</div>
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
<div class="dav-card">
<div class="dav-row"><span>Loading closure details…</span></div>
</div>
} @else {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> How It Closed</div>
<div class="dav-row">
<span>Type</span>
<strong>
{{ closureLabels[closure.closureType] || closure.closureType }}
@if (closure.withinLockin) {
<em class="dav-closure-tag">within lock-in</em>
}
</strong>
</div>
<div class="dav-row">
<span>Closed On</span><strong>{{ closure.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
@if (closure.paidOutBy) {
<div class="dav-row">
<span>Paid Out By</span><strong>{{ closure.paidOutBy }}</strong>
</div>
}
<div class="dav-row">
<span>Closed By</span><strong>{{ closure.closedBy || '—' }}</strong>
</div>
<div class="dav-row">
<span>Approved By</span><strong>{{ closure.approvedBy || '—' }}</strong>
</div>
@if (closure.lockinOverrideReason) {
<div class="dav-row">
<span>Reason</span><strong>{{ closure.lockinOverrideReason }}</strong>
</div>
}
@if (closure.shortfallBorneBy) {
<div class="dav-row">
<span>Cost Borne By</span>
<strong>{{
closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer'
}}</strong>
</div>
}
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> The Money</div>
<div class="dav-row">
<span>Balance at Close</span><strong>₹{{ closure.balanceAtClose || 0 | formatNumber }}</strong>
</div>
@if (closure.interestRecovered > 0) {
<div class="dav-row">
<span>Interest Recovered</span
><strong class="dav-dr">− ₹{{ closure.interestRecovered | formatNumber }}</strong>
</div>
}
@if (closure.interestToppedUpByAgent > 0) {
<div class="dav-row">
<span>Interest Funded by Agent</span
><strong class="dav-cr">+ ₹{{ closure.interestToppedUpByAgent | formatNumber }}</strong>
</div>
}
<div class="dav-row dav-closure-total">
<span>Paid Out</span><strong>₹{{ closure.paidOut || 0 | formatNumber }}</strong>
</div>
@if (closure.closureType === 'PREMATURE') {
<div class="dav-row">
<span>Agent Penalty ({{ closure.agentCode }})</span
><strong>₹{{ closure.agentCommissionPenalty || 0 | formatNumber }}</strong>
</div>
}
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Where It Went</div>
@if (closure.destination === 'OWN_ACCOUNT') {
<div class="dav-row"><span>Paid Into</span><strong>Customer's own account</strong></div>
<div class="dav-row">
<span>Account</span><strong class="dav-mono">{{ closure.creditedAccount?.accountNo }}</strong>
</div>
@if (closure.creditedAccount?.productName) {
<div class="dav-row">
<span>Product</span><strong>{{ closure.creditedAccount.productName }}</strong>
</div>
}
@if (closure.creditedAccount?.owner) {
<div class="dav-row">
<span>Holder</span><strong>{{ closure.creditedAccount.owner }}</strong>
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
</div>
} @else {
<div class="dav-row"><span>Paid Out</span><strong>Nothing - the balance was nil</strong></div>
}
@if (closure.voucherNo) {
<div class="dav-row">
<span>Voucher</span><strong class="dav-mono">{{ closure.voucherNo }}</strong>
</div>
}
@if (closure.payoutDate) {
<div class="dav-row">
<span>Paid On</span><strong>{{ closure.payoutDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
@if (closure.passbookNote) {
<div class="dav-row">
<span>Passbook Note</span><strong>{{ closure.passbookNote }}</strong>
</div>
}
</div>
@if (closure.ledgerEntries?.length) {
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Ledger Entry</div>
<div class="dav-row">
<span>Transaction ID</span><strong class="dav-mono">{{ closure.ledgerTransactionId }}</strong>
</div>
@for (leg of closure.ledgerEntries; track $index) {
<div class="dav-row">
<span>{{ leg.glCode }} · {{ leg.glName }}</span>
<strong [class.dav-dr]="leg.side === 'DR'" [class.dav-cr]="leg.side === 'CR'">
{{ leg.side }} ₹{{ leg.amount | formatNumber }}
</strong>
</div>
}
</div>
}
</div>
</div>
}
}
<!-- General -->
@if (tab === 'general') {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> DDS Details</div>
<div class="dav-row">
<span>Account Number</span><strong class="dav-mono">{{ account.accountNo }}</strong>
</div>
<div class="dav-row">
<span>Party Code</span><strong>{{ account.partyCode }}</strong>
</div>
<div class="dav-row">
<span>Client Name</span><strong>{{ account.clientName }} ({{ account.clientId }})</strong>
</div>
<div class="dav-row">
<span>Agent</span><strong>{{ account.agentCode }} · {{ account.agentName }}</strong>
</div>
<div class="dav-row">
<span>Branch</span><strong>{{ account.officeName }}</strong>
</div>
<div class="dav-row">
<span>Product</span><strong>{{ account.subType }}</strong>
</div>
<div class="dav-row">
<span>Opened On</span><strong>{{ account.openingDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Matures On</span><strong>{{ account.maturityDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Daily Deposit</span><strong>₹{{ account.dailyDepositAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Term</span><strong>{{ account.month }} months · {{ account.days }} days</strong>
</div>
<div class="dav-row">
<span>Interest Rate</span><strong>{{ account.interestRate }} %</strong>
</div>
<div class="dav-row">
<span>Currency</span><strong>Indian Rupee <em class="dav-mono">[INR]</em></strong>
</div>
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Performance History</div>
<div class="dav-row">
<span>Total Collected</span><strong class="dav-cr">₹{{ totalOf('PARTY_TRAN') | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Paid Out</span><strong class="dav-dr">₹{{ totalDebitsAll | formatNumber }}</strong>
</div>
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Account Summary</div>
<div class="dav-row">
<span>Collected to Date</span><strong>₹{{ account.collectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Expected to Date</span><strong>₹{{ account.expectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Standing</span>
<strong>
@if (account.advanceDays > 0) {
{{ account.advanceDays }} days ahead
} @else if (account.arrearsDays > 0) {
<span class="dav-dr">{{ account.arrearsDays }} days behind</span>
} @else {
Up to date
}
</strong>
</div>
<div class="dav-row">
<span>Instalments Due</span><strong>{{ account.instalmentsDue ?? 0 }}</strong>
</div>
<div class="dav-row">
<span>Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Interest Calculated, Not Posted</span
><strong>₹{{ account.interestCalc || 0 | formatNumber }}</strong>
</div>
<div class="dav-row"><span>Interest Calculated Using</span><strong>Daily Balance</strong></div>
<div class="dav-row"><span>Days in Year</span><strong>365 Days</strong></div>
<div class="dav-row">
<span>Last Interest Date</span
><strong>{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Last Transaction Date</span
><strong>{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Maturity Amount</span><strong>₹{{ account.maturityAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Lien</span>
<strong>{{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }}</strong>
</div>
<div class="dav-row">
<span>Freeze</span><strong>{{ account.freezeCode || 'Normal' }}</strong>
</div>
@if (account.closeDate) {
<div class="dav-row">
<span>Closed On</span><strong>{{ account.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
</div>
</div>
</div>
}
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
<!-- Nominees -->
@if (tab === 'nominees') {
<div class="dav-card">
@if (!account.nominees || account.nominees.length === 0) {
<div class="dav-state">
<fa-icon icon="user" size="2x"></fa-icon>
<span>No nominee on this account</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Nominated On</th>
<th>Guardian</th>
<th class="dav-right">{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}</th>
</tr>
</thead>
<tbody>
@for (nominee of account.nominees; track nominee.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>
<strong>{{ nominee.name }}</strong>
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.gender }}</td>
<td>{{ nominee.mobileNo || '—' }}</td>
<td>{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
@@ -0,0 +1,459 @@
/**
* 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/.
*/
/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */
.dav-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
}
.dav-mono {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-style: normal;
}
.dav-cr {
color: #047857;
}
.dav-dr {
color: #b91c1c;
}
.dav-right {
text-align: right !important;
}
/* ── Banner ───────────────────────────────────────────────────────────── */
.dav-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
color: #fff;
background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%);
box-shadow: 0 8px 24px rgb(30 58 138 / 25%);
}
.dav-banner__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.3rem;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-banner__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dav-pill {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
border: 1px solid rgb(167 243 208 / 40%);
}
.dav-pill--closed {
background: rgb(148 163 184 / 25%);
color: #e2e8f0;
border-color: rgb(226 232 240 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.dav-banner__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.dav-banner__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 60%);
}
.dav-banner__value {
font-size: 0.9rem;
font-weight: 600;
}
.dav-banner__boxes {
margin-left: auto;
display: flex;
gap: 12px;
}
.dav-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 18px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-box__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%);
}
.dav-box__value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-banner__actions {
display: flex;
gap: 8px;
}
.dav-icon-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgb(255 255 255 / 35%);
background: rgb(255 255 255 / 12%);
color: #fff;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 22%);
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
.dav-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
align-items: start;
}
.dav-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.dav-card {
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
overflow: hidden;
}
.dav-card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-card__dot {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1e40af);
}
.dav-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px dashed rgb(0 0 0 / 7%);
font-size: 0.92rem;
&:last-child {
border-bottom: 0;
}
span {
color: #334155;
}
strong {
text-align: right;
font-weight: 600;
}
}
.dav-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
background: #fff;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
.dav-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th {
padding: 14px 16px;
text-align: left;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #475569;
border-bottom: 1px solid rgb(0 0 0 / 8%);
white-space: nowrap;
}
td {
padding: 16px;
border-bottom: 1px solid rgb(0 0 0 / 5%);
font-variant-numeric: tabular-nums;
}
tbody tr:nth-child(odd) {
background: #f8fafc;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
}
.dav-banner__boxes {
margin-left: 0;
width: 100%;
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fff3e0;
color: #e65100;
font-size: 0.75rem;
font-style: normal;
}
.dav-closure-total {
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
@@ -0,0 +1,297 @@
/**
* 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 } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
PARTY_TRAN: 'Collection',
INTEREST_POST: 'Interest',
OD_INTEREST_POST: 'OD Interest',
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
/**
* One DDS account and everything that has moved on it, as the savings account page shows a savings
* account: the account's terms and where it stands, then its transactions with the balance after
* each, narrowed to a day or a range.
*
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
* cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-account-view',
templateUrl: './dds-account-view.component.html',
styleUrls: ['./dds-account-view.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
isLoading = true;
account: any = null;
private transactions: any[] = [];
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
closure: any = null;
/** What each kind of closure is called on screen. */
readonly closureLabels: Record<string, string> = {
PREMATURE: 'Premature Close',
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
}
/** The balance after the last entry shown. */
get passbookClosingBalance(): number {
const entries = this.passbookEntries;
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
}
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
return;
}
forkJoin({
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
});
}
// Newest first, and within a day the later entry first - the order a passbook is read back in.
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.isLoading = false;
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
const to = this.toDateControl.value;
return this.transactions.filter((txn: any) => {
const day = String(txn.transactionDate || '');
if (from && day < String(from)) {
return false;
}
if (to && day > String(to)) {
return false;
}
return true;
});
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
get totalDebits(): number {
return this.sumOf('DEBIT');
}
private sumOf(direction: string): number {
return this.shownTransactions
.filter((txn: any) => txn.direction === direction)
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
totalOf(type: string): number {
return this.transactions
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Interest credited, less any taken back on an early closure. */
get interestEarned(): number {
const recovered = this.transactions
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
/** Money that has left the account - payouts, withdrawals, transfers out. */
get totalDebitsAll(): number {
return this.transactions
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
get lastTransactionDate(): string | null {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
'#',
'ID',
'Transaction Date',
'Transaction Type',
'Voucher',
'Description',
'Account',
'Debit',
'Credit',
'Balance'
];
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = this.shownTransactions.map((txn: any, index: number) =>
[
index + 1,
txn.id,
txn.transactionDate,
this.label(txn.transactionType),
txn.voucherNo,
txn.description,
txn.accountNo,
txn.direction === 'DEBIT' ? txn.amount : '',
txn.direction === 'CREDIT' ? txn.amount : '',
txn.balanceAfterTransaction
]
.map(quote)
.join(',')
);
const blob = new Blob(
[[
header.map(quote).join(','),
...lines
].join('\r\n')],
{ type: 'text/csv;charset=utf-8' }
);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
get hasDateFilters(): boolean {
return !!(this.fromDateControl.value || this.toDateControl.value);
}
showToday(): void {
this.fromDateControl.setValue(this.today);
this.toDateControl.setValue(this.today);
}
clearDateFilters(): void {
this.fromDateControl.setValue('');
this.toDateControl.setValue('');
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
statusClass(): string {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
back(): void {
this.router.navigate(['/accounts/dds-accounts']);
}
openInPartyMaster(): void {
this.router.navigate([
'/accounts/dds',
this.account.id,
'edit'
]);
}
}
@@ -0,0 +1,211 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="acl-page">
<!-- Header -->
<div class="acl-header accent--rd">
<div class="acl-header__icon">
<fa-icon icon="coins"></fa-icon>
</div>
<div class="acl-header__text">
<h2 class="acl-header__title">DDS Accounts</h2>
<span class="acl-header__count">
@if (isLoading) {
Loading...
} @else {
{{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed
}
</span>
</div>
</div>
<!-- Search Bar -->
<div class="acl-search-wrap">
<div class="acl-search">
<fa-icon icon="search" class="acl-search__icon"></fa-icon>
<input
[formControl]="searchControl"
class="acl-search__input"
[placeholder]="
searchType === 'accountNo' ? 'Search by account number or party code…' : 'Search by client name…'
"
/>
@if (searchControl.value) {
<button class="acl-search__clear" (click)="clearSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
class="acl-btn acl-btn--toggle"
(click)="toggleSearchType()"
[matTooltip]="'Switch to ' + (searchType === 'accountNo' ? 'client name' : 'account number') + ' search'"
>
<fa-icon icon="exchange-alt"></fa-icon>
{{ searchType === 'accountNo' ? 'Acct No' : 'Client' }}
</button>
<button class="acl-btn acl-btn--icon" (click)="loadAccounts()" matTooltip="Refresh">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
<!-- Status: all accounts, only the open ones, or only the closed -->
<div class="dds-status-filter" role="tablist">
<button
type="button"
class="dds-status-filter__btn"
[class.is-on]="statusFilter === 'ALL'"
(click)="setStatusFilter('ALL')"
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
</div>
<!-- Date Filters: by the day the account was opened -->
<div class="acl-filters">
<div class="acl-filters__group">
<div class="acl-datepick">
<span class="acl-datepick__label">Opened From</span>
<input type="date" class="acl-datepick__input" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="acl-filters__arrow">
<fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="acl-datepick">
<span class="acl-datepick__label">To</span>
<input type="date" class="acl-datepick__input" [formControl]="toDateControl" [max]="today" />
</div>
</div>
<div class="acl-filters__sep"></div>
<div class="acl-datepick acl-datepick--ason">
<span class="acl-datepick__label">
<fa-icon icon="clock"></fa-icon>
As On Date
</span>
<input type="date" class="acl-datepick__input" [formControl]="asOnDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="acl-filters__clear" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon>
Clear
</button>
}
</div>
<!-- Table -->
<div class="acl-card">
@if (isLoading) {
<div class="acl-state acl-state--loading">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading accounts…</span>
</div>
}
@if (!isLoading && rows.length === 0) {
<div class="acl-state acl-state--empty">
<div class="acl-state__icon">
<fa-icon icon="inbox"></fa-icon>
</div>
<p class="acl-state__title">No DDS accounts found</p>
<p class="acl-state__sub">Try adjusting your search</p>
</div>
}
@if (!isLoading && rows.length > 0) {
<div class="acl-table-wrap">
<table mat-table [dataSource]="rows" class="acl-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef class="acl-th">Account No</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-acct-no">{{ row.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositType">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Type</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-type-badge dtype--rd">DDS</span>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Client</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<div class="acl-client">
<div class="acl-client__avatar">{{ (row.clientName || row.partyName || '?')[0] }}</div>
<span class="acl-client__name">{{ row.clientName || row.partyName }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Product</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-product">{{ row.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
<span class="acl-balance">₹ {{ row.currentBalance || 0 | formatNumber }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Actions</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<button class="acl-view-btn" (click)="$event.stopPropagation(); viewAccount(row)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" class="acl-header-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="acl-row" (click)="viewAccount(row)"></tr>
</table>
</div>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
class="acl-paginator"
showFirstLastButtons
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,56 @@
/**
* 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/.
*/
/* All / Active / Closed - which DDS accounts the list shows. */
.dds-status-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
&__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 999px;
background: #fff;
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
&.is-on {
border-color: #1565c0;
background: #e3f2fd;
color: #0d47a1;
}
&--active.is-on {
border-color: #2e7d32;
background: #e8f5e9;
color: #1b5e20;
}
&--closed.is-on {
border-color: #c62828;
background: #ffebee;
color: #b71c1c;
}
}
&__count {
min-width: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgb(0 0 0 / 8%);
font-size: 0.75rem;
text-align: center;
}
}
@@ -0,0 +1,265 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
* balance and status, searched by account number or client and narrowed by opening date. Each opens
* onto the account's own page and its transactions.
*
* Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections,
* the cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-accounts',
templateUrl: './dds-accounts.component.html',
// The Savings Accounts list's own look, so the two screens read as one family.
styleUrls: [
'../../accounts/accounts-list/accounts-list.component.scss',
'./dds-accounts.component.scss'
],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountsComponent implements OnInit, OnDestroy {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private destroy$ = new Subject<void>();
displayedColumns = [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
/** Every DDS account, fetched once; search, dates and paging work on this. */
private allAccounts: any[] = [];
/** The accounts matching the search and dates, whatever their status - what the status counts read. */
private beforeStatus: any[] = [];
/** The accounts matching the search, dates and status, before paging. */
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
pageSize = 20;
pageIndex = 0;
isLoading = false;
searchControl = new FormControl('');
searchType: 'accountNo' | 'clientName' = 'accountNo';
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
asOnDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
get totalRecords(): number {
return this.matching.length;
}
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
}
get hasDateFilters(): boolean {
return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value);
}
ngOnInit(): void {
this.loadAccounts();
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges)
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadAccounts(): void {
this.isLoading = true;
this.organizationService
.getParties()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (parties: any) => {
// Newest first, as the savings list shows them.
this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort(
(a: any, b: any) => Number(b.id) - Number(a.id)
);
this.isLoading = false;
this.applyFilters();
},
error: () => {
this.allAccounts = [];
this.isLoading = false;
this.applyFilters();
}
});
}
/** Search by account number or client, and by the day the account was opened. */
private applyFilters(): void {
const term = String(this.searchControl.value || '')
.trim()
.toLowerCase();
const fromVal = this.fromDateControl.value;
const toVal = this.toDateControl.value;
const bothDates = !!(fromVal && toVal);
const asOn = this.asOnDateControl.value;
this.beforeStatus = this.allAccounts.filter((account: any) => {
if (term) {
const haystack =
this.searchType === 'accountNo'
? `${account.accountNo || ''} ${account.partyCode || ''}`
: `${account.clientName || ''} ${account.partyName || ''}`;
if (!haystack.toLowerCase().includes(term)) {
return false;
}
}
const opened = String(account.openingDate || '');
if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) {
return false;
}
// As on a date: the accounts that had been opened by then.
if (asOn && opened && opened > String(asOn)) {
return false;
}
return true;
});
this.matching =
this.statusFilter === 'ALL'
? this.beforeStatus
: this.beforeStatus.filter((account: any) => account.status === this.statusFilter);
this.showPage();
}
private showPage(): void {
const start = this.pageIndex * this.pageSize;
this.rows = this.matching.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.showPage();
}
toggleSearchType(): void {
this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo';
this.pageIndex = 0;
this.applyFilters();
}
clearSearch(): void {
this.searchControl.setValue('');
}
clearDateFilters(): void {
this.fromDateControl.setValue('', { emitEvent: false });
this.toDateControl.setValue('', { emitEvent: false });
this.asOnDateControl.setValue('', { emitEvent: false });
this.pageIndex = 0;
this.applyFilters();
}
viewAccount(account: any): void {
this.router.navigate([
'/accounts/dds-accounts',
account.id
]);
}
getStatusClass(account: any): string {
switch (account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
}
@@ -0,0 +1,46 @@
/**
* 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 { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Whether the server held a DDS entry for a checker rather than carrying it out. The reply then
* names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own.
*/
export function heldForApproval(response: any): boolean {
return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null);
}
/**
* Tells the clerk their entry went to a checker - with the voucher it will move under - instead of
* saying it was done. Nothing has moved yet, and a message that read as though it had would send them
* looking for money that is still waiting on a manager.
*/
export function announceHeldForApproval(
response: any,
what: string,
organizationService: OrganizationService,
alertService: AlertService
): void {
const say = (voucherNo?: string) =>
alertService.alert({
type: 'Sent for Approval',
message:
`${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` +
'It takes effect once a checker approves it in Approval Center › DDS Approvals.'
});
if (!response?.commandId) {
say();
return;
}
organizationService.getDdsApproval(response.commandId).subscribe({
next: (entry: any) => say(entry?.voucherNo),
error: () => say()
});
}
@@ -0,0 +1,265 @@
<!--
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/.
-->
<!-- Laid out as Disbursement & Transfers reviews its requests, and styled by the same sheet. -->
<div class="dri dri--dialog">
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">DDS {{ entry.type }} Review</span>
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
</nav>
@if (loading) {
<div class="dri-loading">
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ total(debitLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
<!-- Credit entries -->
@if (creditLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
<tbody>
@for (line of creditLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ total(creditLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (entry.status === 'PENDING' && isMine && !done) {
<div class="dri-card dri-card--processing">
<div class="dri-section-title">
<mat-icon>info</mat-icon>
Your own entry
</div>
<p class="dri-processing-text">
You made this entry, so another user with checker rights has to approve or reject it.
</p>
</div>
}
@if (entry.status === 'REJECTED') {
<div class="dri-card dri-card--rejected">
<div class="dri-section-title">
<mat-icon>cancel</mat-icon>
Rejected
</div>
<p class="dri-rejection-text">
Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}.
</p>
@if (entry.checker) {
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
{{ entry.checker }}
</div>
}
</div>
}
@if (entry.status === 'APPROVED') {
<div class="dri-card dri-card--approved">
<div class="dri-section-title">
<mat-icon>check_circle</mat-icon>
Approved and carried out
</div>
<p class="dri-txn-refs cell-mono">
{{
voucherNo
? 'Voucher ' + voucherNo
: entry.entity === 'DDSBATCH'
? 'Posting is running - refresh for its voucher'
: '—'
}}
</p>
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
Approved by: {{ entry.checker || '—' }}
</div>
</div>
}
@if (error) {
<div class="dri-card dri-card--failed">
<div class="dri-section-title">
<mat-icon>error</mat-icon>
Could not complete
</div>
<p class="dri-rejection-text">{{ error }}</p>
</div>
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
{{ working ? 'Processing…' : 'Approve ' + entry.type }}
</button>
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="confirmReject = true">
<mat-icon>cancel</mat-icon>
Reject
</button>
} @else {
<div class="dri-reject-form">
<label class="dri-reject-label">
Reject this entry? Nothing will be done{{
voucherNo ? ' and voucher ' + voucherNo + ' will be cancelled' : ''
}}.
</label>
<div class="dri-reject-actions">
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="act('reject')">
<mat-icon>cancel</mat-icon>
{{ working ? 'Processing…' : 'Confirm Rejection' }}
</button>
<button
type="button"
class="dri-btn dri-btn--ghost"
[disabled]="working"
(click)="confirmReject = false"
>
Cancel
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@@ -0,0 +1,148 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on
* top, who made it and when, then the debit and credit entries it moves - and styled by the same
* sheet, so a checker reads both queues the same way.
*
* The checker approves it, which carries it out under the voucher numbered when it was sent, or
* rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who
* made the entry, and the server refuses it as well.
*/
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule
]
})
export class DdsApprovalReviewComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<DdsApprovalReviewComponent>);
data = inject<any>(MAT_DIALOG_DATA);
entry: any = this.data.entry;
/** The entry in full, with the debit and credit sides it moves. */
detail: any = null;
loading = true;
working = false;
confirmReject = false;
done = false;
error = '';
/** Set by an approval, in case the list row did not carry the number yet. */
approvedVoucher = '';
ngOnInit(): void {
this.load();
}
private load(): void {
this.organizationService.getDdsApproval(this.entry.id).subscribe({
next: (detail: any) => {
this.detail = detail;
this.entry = { ...this.entry, ...detail };
this.loading = false;
},
error: () => (this.loading = false)
});
}
get voucherNo(): string {
return this.approvedVoucher || this.entry?.voucherNo || '';
}
get debitLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'DR');
}
get creditLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
get isMine(): boolean {
return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId);
}
get canAct(): boolean {
return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
APPROVED: 'dri-status--approved',
REJECTED: 'dri-status--rejected'
};
return map[status] ?? '';
}
act(command: 'approve' | 'reject'): void {
this.working = true;
this.error = '';
this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({
next: (result: any) => {
this.working = false;
this.done = true;
this.approvedVoucher = result?.voucherNo || '';
this.entry = {
...this.entry,
status: command === 'approve' ? 'APPROVED' : 'REJECTED',
voucherCancelled: command === 'reject' && !!this.voucherNo
};
// The approved entry's sides are now the voucher's own lines.
this.load();
},
error: (err: any) => {
this.working = false;
const body = err?.error;
this.error =
body?.errors?.[0]?.defaultUserMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
'Could not complete this.';
}
});
}
/** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
close(): void {
this.dialogRef.close(this.done);
}
}
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content wri-tab">
<div class="wri-action-bar">
<div class="wri-action-bar__left">
<mat-icon class="wri-action-bar__icon">savings</mat-icon>
<div>
<div class="wri-action-bar__title">DDS Approvals</div>
<div class="wri-action-bar__sub">
Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings.
</div>
</div>
</div>
</div>
<div class="wri-filter-bar">
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="wri-fb-sep"></span>
<div class="wri-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="wri-preset-chip"
[class.wri-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="wri-fb-sep"></span>
<div class="wri-date-range">
<div class="wri-date-box">
<span class="wri-date-box__lbl">From</span>
<input
class="wri-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="wri-date-range__sep">—</span>
<div class="wri-date-box">
<span class="wri-date-box__lbl">To</span>
<input
class="wri-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && entries.length === 0) {
<div class="empty-state">
<i class="fas fa-inbox empty-icon"></i>
<p class="empty-title">No DDS entries</p>
<p class="empty-text">
@if (filterStatus === 'PENDING') {
There are no DDS entries waiting for approval.
} @else {
No {{ filterStatus | lowercase }} DDS entries found.
}
</p>
</div>
} @else {
<div class="wri-table-wrap">
<table class="wri-table">
<thead>
<tr>
<th class="wri-th-id">Voucher No</th>
<th>Type</th>
<th>Account / Details</th>
<th class="num">Amount</th>
<th>Entries</th>
<th>Clerk</th>
<th>Reviewed By</th>
<th>Submitted At</th>
<th>Status</th>
<th class="wri-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (e of pagedEntries; track e.id) {
<tr class="wri-row" (click)="review(e)">
<td class="cell-mono wri-voucher">
{{ e.voucherNo || '—' }}
@if (e.voucherCancelled) {
<span class="wri-gl-name">(cancelled)</span>
}
</td>
<td>
<span class="wri-credit-badge" [style.background]="typeColor(e)">{{ e.type }}</span>
</td>
<td>
<span class="wri-gl-name">{{ e.details || '—' }}</span>
</td>
<td class="num wri-amount">
{{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }}
</td>
<td class="wri-credit-count">
@if (e.entries) {
<span class="wri-credit-badge">{{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }}</span>
} @else {
—
}
</td>
<td>
{{ e.makerName || e.maker || '—' }}
@if (isMine(e) && e.status === 'PENDING') {
<span class="wri-gl-name">(you)</span>
}
</td>
<td>{{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}</td>
<td class="wri-dt">{{ formatDateTime(e.madeOn) }}</td>
<td>
<span class="wri-status" [ngClass]="statusClass(e.status)">{{ e.status | titlecase }}</span>
</td>
<td class="wri-action-cell" (click)="$event.stopPropagation()">
<button type="button" class="wri-btn wri-btn--review" (click)="review(e)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
[length]="entries.length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@@ -0,0 +1,215 @@
/**
* 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
* it does, on which accounts, for how much - and, once approved, the voucher it moved under.
*
* Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the
* same way. The entries are the platform's own maker-checker entries; approving one here carries it out.
*/
@Component({
selector: 'mifosx-dds-approvals',
templateUrl: './dds-approvals.component.html',
styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatPaginatorModule,
MatNativeDateModule,
MatDatepickerModule
]
})
export class DdsApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
entries: any[] = [];
pageSize = 20;
pageIndex = 0;
readonly pageSizeOptions = [
10,
20,
50
];
/** The page of entries on screen. */
get pagedEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
}
filterStatus = 'PENDING';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'today';
loading = false;
loaded = false;
readonly currentUserId = this.authenticationService.getCredentials()?.userId;
readonly statusOptions = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
ngOnInit(): void {
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
this.maxDate = today;
this.applyPreset('today');
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.load();
}
onToDateChange(): void {
this.activePreset = '';
this.load();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.load();
}
review(entry: any): void {
this.dialog
.open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false })
.afterClosed()
// However the dialog was closed - its Close button, a click outside, Esc - the list is read
// again, so an entry just approved or rejected leaves the Pending list at once.
.subscribe(() => this.load());
}
/** Whose entry it is - a checker cannot pass their own. */
isMine(entry: any): boolean {
return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'wri-status--pending',
APPROVED: 'wri-status--approved',
REJECTED: 'wri-status--rejected'
};
return map[status] ?? '';
}
typeColor(entry: any): string {
const map: Record<string, string> = {
Transfer: '#e3f2fd',
'Apply Receipt': '#fce4ec'
};
if (String(entry.type).startsWith('Premature')) {
return '#fff3e0';
}
if (String(entry.type).startsWith('Maturity')) {
return '#e8f5e9';
}
if (String(entry.type).startsWith('Post')) {
return '#ede7f6';
}
return map[entry.type] ?? '#f5f5f5';
}
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
private isoDate(d: Date | null): string | undefined {
if (!d) {
return undefined;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -0,0 +1,49 @@
/**
* 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 { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
/**
* Finds clients by name whatever case the officer types it in.
*
* The platform's name filter matches anywhere in the name but only in the exact case typed, so
* "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term
* is tried as typed, capitalised word by word, and in upper and lower case, and the results merged.
* Done here rather than by changing the platform's search, which every other screen relies on.
*/
export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable<any[]> {
const variants = Array.from(
new Set([
term,
term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()),
term.toUpperCase(),
term.toLowerCase()
])
);
return forkJoin(
variants.map((variant) =>
clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe(
map((response: any) => {
const found = response?.pageItems || response || [];
return Array.isArray(found) ? found : [];
}),
catchError(() => of([]))
)
)
).pipe(
map((lists: any[][]) => {
const byId = new Map<number, any>();
for (const client of lists.flat()) {
byId.set(client.id, client);
}
return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName)));
})
);
}
@@ -26,7 +26,9 @@
<div class="pm-section__header">
<i class="fas fa-id-card"></i>
<h3>Customer &amp; Scheme</h3>
<span class="pm-section__sub">Who this account is for, which scheme it runs under, and which agent collects for it</span>
<span class="pm-section__sub"
>Who this account is for, which scheme it runs under, and which agent collects for it</span
>
</div>
<div class="pm-grid">
<div class="pm-f">
@@ -42,7 +44,11 @@
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
<mat-autocomplete
#clientAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displayClient"
>
@for (client of clientOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
@@ -81,7 +87,11 @@
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #subTypeAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displaySubType">
<mat-autocomplete
#subTypeAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displaySubType"
>
@for (type of subTypeOptions; track type.id) {
<mat-option [value]="type.accountTypeCode">
<div class="pm-opt">
@@ -114,7 +124,11 @@
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #agentAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayAgent">
<mat-autocomplete
#agentAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displayAgent"
>
@for (agent of agentOptions; track agent.id) {
<mat-option [value]="agent">
<div class="pm-opt">
@@ -163,20 +177,37 @@
</mat-form-field>
</div>
<!--
<!--
The customer's real deposit account. Three states, and the difference matters: an
account is open, this party predates DDS opening them, or the party is new and the
account is about to be opened. It used to read "Linked (id 36)", which is a number
only the database means anything by.
-->
@if (savingsAccountNo) {
@if (accountNo) {
<!-- The party is the DDS account now, so its number, scheme and balance are its own. -->
<div class="pm-f">
<label class="pm-f-lbl">DDS Account</label>
<div class="pm-account">
<span class="pm-account-no">{{ accountNo }}</span>
<span class="pm-account-meta">
{{ accountSchemeName }} ·
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{
partyStatus === 'ACTIVE' ? 'Active' : partyStatus
}}</span>
· {{ accountBalance | number: '1.2-2' }}
</span>
</div>
</div>
} @else if (savingsAccountNo) {
<div class="pm-f">
<label class="pm-f-lbl">DDS Account</label>
<div class="pm-account">
<span class="pm-account-no">{{ savingsAccountNo }}</span>
<span class="pm-account-meta">
{{ savingsProductName }} ·
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{ savingsAccountStatus }}</span>
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{
savingsAccountStatus
}}</span>
· {{ savingsAccountBalance | number: '1.2-2' }}
</span>
</div>
@@ -224,7 +255,7 @@
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Party Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="partyName" />
<input matInput required formControlName="partyName" [readonly]="isFromClient('partyName')" />
</mat-form-field>
</div>
@@ -234,39 +265,45 @@
<input matInput formControlName="partyName1" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 1<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="address1" />
<input matInput required formControlName="address1" [readonly]="isFromClient('address1')" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 2</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="address2" />
<input matInput formControlName="address2" [readonly]="isFromClient('address2')" />
</mat-form-field>
</div>
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Address 3</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="address3" />
<input matInput formControlName="address3" [readonly]="isFromClient('address3')" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">City<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="city" />
<input matInput required formControlName="city" [readonly]="isFromClient('city')" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Pincode<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="pincode" mifosxRestrictInput="digits" />
<input
matInput
required
formControlName="pincode"
[readonly]="isFromClient('pincode')"
mifosxRestrictInput="digits"
/>
</mat-form-field>
</div>
@@ -284,35 +321,61 @@
<div class="pm-f">
<label class="pm-f-lbl">Telephone</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
<input
matInput
formControlName="telephone"
[readonly]="isFromClient('telephone')"
mifosxRestrictInput="phone"
/>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No (SMS)<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="mobileNoSms" mifosxRestrictInput="digits" />
<input
matInput
required
formControlName="mobileNoSms"
[readonly]="isFromClient('mobileNoSms')"
mifosxRestrictInput="digits"
/>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Aadhar No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="aadharNo" mifosxRestrictInput="aadhaar" />
<input
matInput
formControlName="aadharNo"
[readonly]="isFromClient('aadharNo')"
mifosxRestrictInput="aadhaar"
/>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">PAN Card No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="panCardNo" mifosxRestrictInput="pan" />
<input
matInput
formControlName="panCardNo"
[readonly]="isFromClient('panCardNo')"
mifosxRestrictInput="pan"
/>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">CKYC ID</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="ckycId" mifosxRestrictInput="digits" />
<input
matInput
formControlName="ckycId"
[readonly]="isFromClient('ckycId')"
mifosxRestrictInput="digits"
/>
</mat-form-field>
</div>
@@ -330,7 +393,12 @@
<div class="pm-f">
<label class="pm-f-lbl">Birth Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
<input
matInput
[matDatepicker]="birthPicker"
formControlName="birthDate"
[readonly]="isFromClient('birthDate')"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
<mat-datepicker #birthPicker></mat-datepicker>
</mat-form-field>
@@ -364,7 +432,9 @@
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Minor / Guardian</h3>
<span class="pm-section__sub">Only for a minor's account — the guardian who operates it until they come of age</span>
<span class="pm-section__sub"
>Only for a minor's account — the guardian who operates it until they come of age</span
>
</div>
<div class="pm-grid">
<div class="pm-f">
@@ -382,7 +452,13 @@
<div class="pm-f">
<label class="pm-f-lbl">Minor Date<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="minorPicker" formControlName="minorDate" />
<input
matInput
required
[matDatepicker]="minorPicker"
formControlName="minorDate"
[readonly]="isFromClient('minorDate')"
/>
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
<mat-datepicker #minorPicker></mat-datepicker>
<mat-hint>Turns 18 on</mat-hint>
@@ -396,12 +472,17 @@
matInput
required
formControlName="guardianClientId"
[readonly]="isFromClient('guardianClientId')"
[matAutocomplete]="guardianAuto"
placeholder="Type an ID or search by name…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete #guardianAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
<mat-autocomplete
#guardianAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displayClient"
>
@for (client of guardianOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
@@ -428,7 +509,7 @@
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="relation" />
<input matInput required formControlName="relation" [readonly]="isFromClient('relation')" />
<mat-hint>To the minor</mat-hint>
</mat-form-field>
</div>
@@ -535,24 +616,8 @@
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Interest Rate</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" formControlName="interestRate" />
<span matTextSuffix>%</span>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Withdraw Type</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="withdrawType">
@for (type of withdrawTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Interest Rate is the product's (Sub Type), not typed here. -->
<!-- Withdraw Type is decided when the account is paid out, not when it is opened. -->
</div>
</div>
@@ -610,7 +675,13 @@
<div class="pm-f">
<label class="pm-f-lbl">Please specify<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required maxlength="200" formControlName="lienReasonOther" placeholder="e.g. Loan #1234" />
<input
matInput
required
maxlength="200"
formControlName="lienReasonOther"
placeholder="e.g. Loan #1234"
/>
<mat-hint>Write the reason in your own words</mat-hint>
</mat-form-field>
</div>
@@ -687,17 +758,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Adv Code</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="advCode">
@for (code of advCodes; track code) {
<mat-option [value]="code">{{ code }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!-- Adv Code is decided when the account is paid out, not when it is opened. -->
</div>
</div>
@@ -723,7 +784,7 @@
</mat-form-field>
</div>
<!--
<!--
Picked from the customer's own savings accounts, never typed. The whole matured
amount lands here, and a number that exists only as text is a payout with
nowhere to go - which is what one party was already carrying.
@@ -790,7 +851,10 @@
@if (splitByPercentage && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated@if (nomineeShareError) { — {{ nomineeShareError }} }
{{ nomineeShareTotal }}% of 100% allocated
@if (nomineeShareError) {
— {{ nomineeShareError }}
}
</span>
} @else if (splitBySequence && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
@@ -865,7 +929,10 @@
</div>
<div class="pm-f">
<label class="pm-f-lbl">
Guar. Name@if (isNomineeMinor(nominee)) {<b>*</b>}
Guar. Name
@if (isNomineeMinor(nominee)) {
<b>*</b>
}
</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
@@ -878,7 +945,15 @@
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Share %<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="0.01" max="100" step="0.01" required formControlName="sharePercent" />
<input
matInput
type="number"
min="0.01"
max="100"
step="0.01"
required
formControlName="sharePercent"
/>
</mat-form-field>
</div>
} @else {
@@ -922,8 +997,9 @@
<i class="fas fa-user-clock"></i>
<span>
This account was opened for a minor who came of age on
<strong>{{ majorOn | dateFormat }}</strong>. The guardian can no longer
operate it — take fresh KYC, a signature and a new mandate from the account holder.
<strong>{{ majorOn | dateFormat }}</strong
>. The guardian can no longer operate it — take fresh KYC, a signature and a new mandate from the account
holder.
</span>
</div>
}
@@ -32,6 +32,8 @@ import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { searchClientsAnyCase } from '../client-search';
/** Custom Components */
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
@@ -84,6 +86,7 @@ export class CreatePartyComponent implements OnInit {
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
@@ -130,6 +133,11 @@ export class CreatePartyComponent implements OnInit {
savingsAccountStatus: string | null = null;
savingsAccountBalance: number | null = null;
/** The DDS account's own number, scheme and balance - the party is the account. */
accountNo: string | null = null;
accountSchemeName: string | null = null;
accountBalance: number | null = null;
/**
* A minor account holder who has come of age. The guardian's authority ended on that day, so the
* branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field.
@@ -271,9 +279,20 @@ export class CreatePartyComponent implements OnInit {
'gender',
'birthDate',
'telephone',
'mobileNoSms'
'mobileNoSms',
'occupation',
'risk',
'aadharNo',
'panCardNo',
'ckycId',
'relation',
'minorDate',
'guardianClientId'
];
/** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */
private filledFromClient = new Set<string>();
private static readonly MINOR_FIELDS = [
'minorDate',
'guardianClientId',
@@ -323,7 +342,10 @@ export class CreatePartyComponent implements OnInit {
'No',
Validators.required
],
aadharNo: ['', Validators.pattern(CreatePartyComponent.AADHAAR_RE)],
aadharNo: [
'',
Validators.pattern(CreatePartyComponent.AADHAAR_RE)
],
partyName: [
'',
Validators.required
@@ -347,8 +369,14 @@ export class CreatePartyComponent implements OnInit {
]
],
gender: [''],
panCardNo: ['', Validators.pattern(CreatePartyComponent.PAN_RE)],
telephone: ['', Validators.pattern(CreatePartyComponent.TELEPHONE_RE)],
panCardNo: [
'',
Validators.pattern(CreatePartyComponent.PAN_RE)
],
telephone: [
'',
Validators.pattern(CreatePartyComponent.TELEPHONE_RE)
],
mobileNoSms: [
'',
[
@@ -363,7 +391,10 @@ export class CreatePartyComponent implements OnInit {
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: ['', Validators.pattern(CreatePartyComponent.CKYC_RE)],
ckycId: [
'',
Validators.pattern(CreatePartyComponent.CKYC_RE)
],
adultMinor: [
'Adult',
Validators.required
@@ -511,8 +542,10 @@ export class CreatePartyComponent implements OnInit {
this.officesError = this.offices.length === 0 ? 'No branches on file' : '';
},
error: (error: any) => {
this.officesError = error?.error?.defaultUserMessage || error?.error?.developerMessage
|| `Could not load branches (${error?.status || 'no response'})`;
this.officesError =
error?.error?.defaultUserMessage ||
error?.error?.developerMessage ||
`Could not load branches (${error?.status || 'no response'})`;
}
});
@@ -547,6 +580,8 @@ export class CreatePartyComponent implements OnInit {
for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) {
this.partyForm.get(controlName)?.setValue('');
}
this.filledFromClient.clear();
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
this.loadedClientName = '';
this.clientLookupFailed = false;
@@ -574,13 +609,9 @@ export class CreatePartyComponent implements OnInit {
}
private searchClients(term: string): void {
this.clientsService.getFilteredClients('displayName', 'ASC', false, term).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.showingClientList = false;
this.clientOptions = Array.isArray(found) ? found.slice(0, 25) : [];
},
error: () => (this.clientOptions = [])
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
@@ -624,13 +655,54 @@ export class CreatePartyComponent implements OnInit {
next: (client: any) => {
this.loadedClientName = client?.displayName || knownName || '';
this.clientLookupFailed = false;
this.setField('partyName', client?.displayName);
this.setField('mobileNoSms', client?.mobileNo);
this.setField('gender', client?.gender?.name || client?.gender);
this.fillFromClientRecord('partyName', client?.displayName);
this.fillFromClientRecord('mobileNoSms', client?.mobileNo);
this.fillFromClientRecord('telephone', client?.alternateMobileNo);
// Alerts go where the client can actually be reached: SMS with a mobile on record, email with
// an email address on record.
this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo);
this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress);
// The client record says FEMALE; this form's list says Female.
this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender));
const dateOfBirth = this.toDate(client?.dateOfBirth);
if (dateOfBirth) {
this.partyForm.get('birthDate')?.setValue(dateOfBirth);
this.filledFromClient.add('birthDate');
}
// The client's own occupation, offered even when this form's short list does not carry it.
const occupation = client?.occupation?.name || client?.occupationCategory?.name;
if (occupation) {
if (!this.occupations.includes(occupation)) {
this.occupations = [
...this.occupations,
occupation
];
}
this.fillFromClientRecord('occupation', occupation);
}
// A minor on the client record is a minor account here, with the same guardian.
this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult');
// Adult or minor is the client record's, checked when the client was opened - not changed here.
this.partyForm.get('adultMinor')?.disable({ emitEvent: false });
this.filledFromClient.add('adultMinor');
if (client?.isMinor) {
if (dateOfBirth) {
// The day they come of age - eighteen is majority in India.
const majority = new Date(dateOfBirth);
majority.setFullYear(majority.getFullYear() + 18);
this.partyForm.get('minorDate')?.setValue(majority);
this.filledFromClient.add('minorDate');
}
if (client?.guardianClientId) {
this.partyForm
.get('guardianClientId')
?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' });
this.filledFromClient.add('guardianClientId');
}
this.fillFromClientRecord('relation', titleCase(client?.guardianRelation));
}
this.loadClientRisk(String(clientId));
// Nominees are not brought across: the officer records them for this account.
// Proceeds usually go back to an account at the customer's own branch, so that is offered
// first - any branch can still be picked, since the account may well be at another.
const creditToBranch = this.partyForm.get('creditToBranch');
@@ -692,11 +764,11 @@ export class CreatePartyComponent implements OnInit {
continue;
}
if (words.includes('aadhaar') || words.includes('aadhar')) {
this.setField('aadharNo', documentKey);
this.fillFromClientRecord('aadharNo', documentKey);
} else if (words.includes('pan')) {
this.setField('panCardNo', documentKey);
this.fillFromClientRecord('panCardNo', documentKey);
} else if (words.includes('ckyc')) {
this.setField('ckycId', documentKey);
this.fillFromClientRecord('ckycId', documentKey);
}
}
},
@@ -713,6 +785,39 @@ export class CreatePartyComponent implements OnInit {
return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value);
};
/**
* Takes a value from the client record, which was checked when the client was opened, and locks
* the field so it is not typed - and checked - a second time. A value the client does not have is
* left for the officer to enter.
*/
private fillFromClientRecord(controlName: string, value: any): void {
if (value === null || value === undefined || String(value).trim() === '') {
return;
}
this.setField(controlName, value);
this.filledFromClient.add(controlName);
}
/** Whether a field holds a value taken from the client record, and so is shown read-only. */
isFromClient(controlName: string): boolean {
return this.filledFromClient.has(controlName);
}
/** The client's risk category, from their KYC record. */
private loadClientRisk(clientId: string): void {
this.clientsService.getClientKyc(clientId, true).subscribe({
next: (kyc: any) => {
// kycRiskCategory.medium -> Medium
const code = String(kyc?.riskCategoryCode || '')
.split('.')
.pop();
this.fillFromClientRecord('risk', titleCase(code));
},
// A client without a KYC record just leaves the risk to be chosen.
error: () => undefined
});
}
private loadClientAddress(clientId: string): void {
this.clientsService.getClientAddressData(clientId, true).subscribe({
next: (addresses: any) => {
@@ -720,11 +825,11 @@ export class CreatePartyComponent implements OnInit {
if (!address) {
return;
}
this.setField('address1', address.addressLine1 || address.street);
this.setField('address2', address.addressLine2);
this.setField('address3', address.addressLine3);
this.setField('city', address.city);
this.setField('pincode', address.postalCode);
this.fillFromClientRecord('address1', address.addressLine1 || address.street);
this.fillFromClientRecord('address2', address.addressLine2);
this.fillFromClientRecord('address3', address.addressLine3);
this.fillFromClientRecord('city', address.city);
this.fillFromClientRecord('pincode', address.postalCode);
},
// A client with no address on file is normal - the officer just types it.
error: () => undefined
@@ -802,8 +907,12 @@ export class CreatePartyComponent implements OnInit {
private filterAgents(term: string): void {
const needle = term.toLowerCase();
const match = (agent: any) =>
String(agent.agentCode || '').toLowerCase().includes(needle) ||
String(agent.agentNameId || '').toLowerCase().includes(needle) ||
String(agent.agentCode || '')
.toLowerCase()
.includes(needle) ||
String(agent.agentNameId || '')
.toLowerCase()
.includes(needle) ||
String(agent.id) === term;
if (this.allAgents.length > 0) {
@@ -868,14 +977,28 @@ export class CreatePartyComponent implements OnInit {
*/
private wireSubTypeSearch(): void {
this.partyForm.get('subType')?.valueChanges.subscribe((value) => {
const term = String(value ?? '').toLowerCase().trim();
// A new account takes its product's rate, which the maturity preview is worked out on. The
// server sets the same rate when it saves; an account being edited already has it.
const scheme = this.subTypes.find((type) => type.accountTypeCode === value);
if (scheme && !this.partyId) {
this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0);
}
const term = String(value ?? '')
.toLowerCase()
.trim();
this.subTypeOptions = !term
? this.subTypes
: this.subTypes.filter(
(type) =>
String(type.accountTypeCode || '').toLowerCase().includes(term) ||
String(type.accountTypeDescription || '').toLowerCase().includes(term) ||
String(type.accountTypeGroup || '').toLowerCase().includes(term)
String(type.accountTypeCode || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeDescription || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeGroup || '')
.toLowerCase()
.includes(term)
);
});
}
@@ -970,8 +1093,6 @@ export class CreatePartyComponent implements OnInit {
return this.partyForm.get('nomineeApplicable')?.value === 'Yes';
}
/**
* Which entry in the list a stored reason belongs to.
*
@@ -1051,7 +1172,10 @@ export class CreatePartyComponent implements OnInit {
*/
private wireMaturityCalculation(): void {
// The term drives everything downstream, so changing it refills Days as well.
for (const controlName of ['openingDate', 'month']) {
for (const controlName of [
'openingDate',
'month'
]) {
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm());
}
// Days typed by hand stands: it only moves the maturity date when no month was given.
@@ -1059,7 +1183,10 @@ export class CreatePartyComponent implements OnInit {
this.recalculateMaturityDate();
this.recalculateMaturityAmount();
});
for (const controlName of ['dailyDepositAmount', 'interestRate']) {
for (const controlName of [
'dailyDepositAmount',
'interestRate'
]) {
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount());
}
}
@@ -1131,7 +1258,11 @@ export class CreatePartyComponent implements OnInit {
this.partyForm
.get('maturityAmount')
?.setValue(
this.maturityAmountOf(Number(raw.dailyDepositAmount) || 0, Number(raw.days) || 0, Number(raw.interestRate) || 0),
this.maturityAmountOf(
Number(raw.dailyDepositAmount) || 0,
Number(raw.days) || 0,
Number(raw.interestRate) || 0
),
{ emitEvent: false }
);
}
@@ -1372,6 +1503,9 @@ export class CreatePartyComponent implements OnInit {
this.savingsProductName = null;
this.savingsAccountStatus = null;
this.savingsAccountBalance = null;
this.accountNo = null;
this.accountSchemeName = null;
this.accountBalance = null;
this.majorOn = null;
this.minorTurnedMajor = false;
this.loadedClientName = '';
@@ -1451,7 +1585,7 @@ export class CreatePartyComponent implements OnInit {
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
ledgerNo: raw.ledgerNo || undefined,
interestRate: raw.interestRate || 0,
// No interest rate: the server gives the account its product's rate.
withdrawType: raw.withdrawType || undefined,
lien: raw.lien === 'Yes',
// Sent only when the money is actually held. Releasing it clears the reason on the
@@ -1489,10 +1623,24 @@ export class CreatePartyComponent implements OnInit {
}))
};
const partyLabel = raw.partyCode || raw.partyName;
const done = (response: any) => {
// Under maker-checker the command is only recorded, and nothing has been saved yet.
if (!!response?.commandId && response?.resourceId == null) {
this.alertService.alert({
type: 'Sent for Approval',
message: this.partyId
? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.`
: `New party ${partyLabel} was sent for a manager's approval and will be created once approved.`
});
}
this.exit();
};
if (this.partyId) {
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else {
this.organizationService.createParty(payload).subscribe(() => this.exit());
this.organizationService.createParty(payload).subscribe(done);
}
}
@@ -1504,7 +1652,7 @@ export class CreatePartyComponent implements OnInit {
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
@@ -1533,7 +1681,12 @@ export class CreatePartyComponent implements OnInit {
private get agentCodeOfForm(): string {
const value = this.partyForm.get('agentId')?.value;
return value && typeof value === 'object' ? value.agentCode : String(value ?? '');
if (value && typeof value === 'object') {
return value.agentCode;
}
// The form holds the agent's id; the dialog names them by code.
const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value));
return agent?.agentCode ?? String(value ?? '');
}
get canCloseEarly(): boolean {
@@ -1562,6 +1715,7 @@ export class CreatePartyComponent implements OnInit {
interestRate: this.partyForm.get('interestRate')?.value,
maturityDate: this.partyForm.get('maturityDate')?.value,
currentBalance: this.partyForm.get('currentBalance')?.value,
openingDate: this.partyForm.get('openingDate')?.value,
clientId: this.clientIdOfForm,
agentCode: this.agentCodeOfForm
},
@@ -1573,20 +1727,35 @@ export class CreatePartyComponent implements OnInit {
if (!closeCommand) {
return;
}
const partyCode = this.partyForm.get('partyCode')?.value;
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
const withinLockin = !!closeCommand.lockinOverrideReason;
// The date goes up the way every other date on this system does - formatted to the user's own
// setting, with that setting and their locale alongside so the server reads it back the same
// way. An ordinary close sends it like this too; two ways of closing an account should not
// disagree about something as plain as what day it is.
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.prematureCloseParty(this.partyId as number, {
...closeCommand,
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
const request = {
...closeCommand,
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
};
(withinLockin
? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request)
: this.organizationService.prematureCloseParty(this.partyId as number, request)
).subscribe((response: any) => {
// Under maker-checker the command is only recorded, and nothing has closed yet.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
message: awaitingApproval
? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.`
: `${partyCode} is closed.`
});
this.exit();
});
// A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's
// own error handler, which names the reason. Repeating it here would say the same thing twice.
});
@@ -1662,6 +1831,9 @@ export class CreatePartyComponent implements OnInit {
this.savingsProductName = party.savingsProductName || null;
this.savingsAccountStatus = party.savingsAccountStatus || null;
this.savingsAccountBalance = party.savingsAccountBalance ?? null;
this.accountNo = party.accountNo || null;
this.accountSchemeName = party.subTypeDescription || party.subType || null;
this.accountBalance = party.currentBalance ?? null;
this.majorOn = party.majorOn || null;
this.minorTurnedMajor = party.minorTurnedMajor === true;
this.loadedClientName = party.clientName || '';
@@ -1762,3 +1934,11 @@ export class CreatePartyComponent implements OnInit {
}
}
}
/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */
function titleCase(value: any): string {
return String(value ?? '')
.toLowerCase()
.replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase())
.trim();
}
@@ -32,7 +32,10 @@
<div class="pl-kpi-l">Outstanding</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal" matTooltip="Money the bank is holding on matured accounts it has not yet paid out">
<div
class="pl-kpi pl-kpi--teal"
matTooltip="Money the bank is holding on matured accounts it has not yet paid out"
>
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalPayable | number: '1.2-2' }}</div>
@@ -82,7 +85,7 @@
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.partyCode }}</div>
<div class="pl-product-sub dfc-account">{{ party.savingsAccountNo || '—' }}</div>
<div class="pl-product-sub dfc-account">{{ party.accountNo || party.savingsAccountNo || '—' }}</div>
</div>
</td>
<td>{{ party.partyName }}</td>
@@ -141,12 +141,20 @@ export class DueForClosureComponent implements OnInit {
return;
}
this.organizationService.payOutParty(party.id, payoutCommand).subscribe({
next: () => {
next: (result: any) => {
if (awaitingApproval(result)) {
this.alertService.alert({
type: 'Sent for Approval',
message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
});
this.load();
return;
}
this.alertService.alert({
type: 'Party Paid Out',
message: payoutCommand.toSavingsAccountNo
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo}. Close the account once you have checked it.`
: `${party.partyCode} paid into Matured Deposits Payable, ready to transfer out. Close the account once you have checked it.`
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.`
: `${party.partyCode} parked in Sundry in the customer's name and the account is closed.`
});
this.load();
}
@@ -179,16 +187,28 @@ export class DueForClosureComponent implements OnInit {
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(party.id, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Party Closed', message: `${party.partyCode} is closed.` });
next: (result: any) => {
this.alertService.alert(
awaitingApproval(result)
? {
type: 'Sent for Approval',
message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.`
}
: { type: 'Party Closed', message: `${party.partyCode} is closed.` }
);
this.load();
}
});
});
}
}
/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */
function awaitingApproval(result: any): boolean {
return !!result?.commandId && result?.resourceId == null;
}
@@ -47,7 +47,9 @@
paid in full, waiting to be closed.
}
</span>
<span class="pm-due-banner__cta">Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon></span>
<span class="pm-due-banner__cta"
>Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon
></span>
</a>
}
@@ -59,7 +61,10 @@
<div class="pl-kpi-l">Open Accounts</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple" matTooltip="What the agents should collect between them on a full collection day">
<div
class="pl-kpi pl-kpi--purple"
matTooltip="What the agents should collect between them on a full collection day"
>
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
@@ -79,7 +84,11 @@
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code, name or agent…" />
<input
type="text"
(input)="applyFilter($any($event.target).value)"
placeholder="Search by code, name or agent…"
/>
</div>
</div>
@@ -131,14 +140,19 @@
</td>
</ng-container>
<!--
<!--
The customer's real deposit account. A party without one is money the bank's books do not
know about, so it is called out rather than shown as a blank cell.
-->
<ng-container matColumnDef="savingsAccountNo">
<th mat-header-cell *matHeaderCellDef>DDS Account</th>
<td mat-cell *matCellDef="let p">
@if (p.savingsAccountNo) {
@if (p.accountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.accountNo }}</div>
<div class="pl-product-sub">{{ p.subTypeDescription || p.subType }}</div>
</div>
} @else if (p.savingsAccountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.savingsAccountNo }}</div>
<div class="pl-product-sub">{{ p.savingsAccountStatus }}</div>
@@ -117,8 +117,24 @@ export class PartyMasterComponent implements OnInit {
parties: any[] = [];
filteredCount = 0;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
paginator: MatPaginator;
sort: MatSort;
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
@ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) {
this.paginator = paginator;
if (paginator) {
this.dataSource.paginator = paginator;
}
}
@ViewChild(MatSort) set matSort(sort: MatSort) {
this.sort = sort;
if (sort) {
this.dataSource.sort = sort;
}
}
get totalCount(): number {
return this.parties.length;
@@ -163,7 +179,10 @@ export class PartyMasterComponent implements OnInit {
next: (due: any) => {
const rows = Array.isArray(due) ? due : due?.pageItems || [];
this.dueForClosureCount = rows.length;
this.dueForClosurePayable = rows.reduce((sum: number, party: any) => sum + Number(party.currentBalance || 0), 0);
this.dueForClosurePayable = rows.reduce(
(sum: number, party: any) => sum + Number(party.currentBalance || 0),
0
);
},
// The party list is the point of this screen; a failed banner must not take it down with it.
error: () => {
@@ -20,11 +20,67 @@
<span class="po-summary__label">Matured on</span>
<span class="po-summary__value">{{ data.maturityDate | dateFormat }}</span>
</div>
<div class="po-summary__row po-summary__row--amount">
<span class="po-summary__label">Amount payable</span>
<span class="po-summary__value">{{ data.currentBalance | number: '1.2-2' }}</span>
<div class="po-summary__row">
<span class="po-summary__label">Opened on</span>
<span class="po-summary__value">{{ data.openingDate | dateFormat }}</span>
</div>
@if (breakdown) {
<div class="po-breakdown">
<div class="po-summary__row">
<span class="po-summary__label">Deposits collected</span>
<span class="po-summary__value">{{ breakdown.deposits | number: '1.2-2' }}</span>
</div>
@if (breakdown.transfersIn > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred in</span>
<span class="po-summary__value">+ {{ breakdown.transfersIn | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.transfersOut > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred out</span>
<span class="po-summary__value">− {{ breakdown.transfersOut | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.other !== 0) {
<div class="po-summary__row">
<span class="po-summary__label">Other (opening balance / adjustments)</span>
<span class="po-summary__value">{{ breakdown.other | number: '1.2-2' }}</span>
</div>
}
<div class="po-summary__row">
<span class="po-summary__label">
Interest @ {{ data.interestRate | number: '1.2-2' }}%
<span class="po-summary__note"
>{{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }}</span
>
</span>
<span class="po-summary__value">+ {{ breakdown.interest | number: '1.2-2' }}</span>
</div>
@if (breakdown.interestToPost > 0) {
<div class="po-summary__row">
<span class="po-summary__label"> Interest calculated, posted with this payout </span>
<span class="po-summary__value">+ {{ breakdown.interestToPost | number: '1.2-2' }}</span>
</div>
}
</div>
}
<div class="po-summary__row po-summary__row--amount">
<span class="po-summary__label">Total payable</span>
<span class="po-summary__value">{{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }}</span>
</div>
@if (interestStillToCome) {
<p class="po-summary__note">
Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet
- it is added when this is paid out, so the amount paid will be a little more.
</p>
}
</div>
<p class="po-summary__note">
Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more.
</p>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
@@ -46,28 +102,57 @@
<mat-label>Account</mat-label>
<mat-select [(ngModel)]="selectedAccountNo" panelWidth="min(28rem, 90vw)">
@for (account of ownAccounts; track account.id) {
<mat-option [value]="account.accountNo">
{{ account.accountNo }} · {{ account.productName }}
</mat-option>
<mat-option [value]="account.accountNo"> {{ account.accountNo }} · {{ account.productName }} </mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-radio-button value="other" class="po-choice">
<span class="po-choice__title">To an account at another bank</span>
<span class="po-choice__note">
Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers
</span>
<span class="po-choice__title">Park in Sundry</span>
<span class="po-choice__note"
>When the customer has no account here - held in the bank's Sundry account in their name</span
>
</mat-radio-button>
</mat-radio-group>
@if (destination === 'other') {
<mat-form-field appearance="outline" class="po-account">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (destination === 'other') {
<!-- Said plainly, because the officer will be asked by the customer when the money arrives. -->
<div class="po-callout">
The money comes off {{ data.partyCode }} today and sits in the bank's Matured Deposits Payable
account until the transfer is made from Fund Transfers — so it is accounted for the whole
time, and a failed transfer loses nothing.
The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against
{{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost.
</div>
}
}
@@ -75,5 +160,5 @@
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close>Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out</button>
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out &amp; Close</button>
</mat-dialog-actions>
@@ -38,6 +38,22 @@
font-weight: 500;
font-variant-numeric: tabular-nums;
}
&__note {
display: block;
margin: 0;
font-size: 0.75rem;
color: rgb(0 0 0 / 45%);
}
}
/* How the amount payable is made up - one line per source, adding up to the total below. */
.po-breakdown {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed rgb(0 0 0 / 10%);
}
.po-question {
@@ -90,3 +106,7 @@
font-size: 0.78rem;
line-height: 1.5;
}
.po-hint-warn {
color: #c62828;
}
@@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@@ -46,6 +47,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
@@ -72,9 +74,90 @@ export class PayoutDialogComponent implements OnInit {
*/
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* How the amount payable was made up - what was deposited, what came in or went out by transfer,
* and the interest - read from the account's own transactions so the officer can explain the
* figure to the customer line by line. Null until the transactions have arrived.
*/
breakdown: {
deposits: number;
transfersIn: number;
transfersOut: number;
interest: number;
other: number;
interestToPost: number;
total: number;
} | null = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])),
error: () => (this.breakdown = null)
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
@@ -96,14 +179,52 @@ export class PayoutDialogComponent implements OnInit {
});
}
/** Interest is still to be worked out for part of the term, and the payout will add it. */
get interestStillToCome(): boolean {
return (
!!this.data.interestDate &&
!!this.data.maturityDate &&
String(this.data.interestDate) < String(this.data.maturityDate)
);
}
private breakdownOf(transactions: any[]) {
const sum = (test: (txn: any) => boolean) =>
transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0);
const signed = (type: string) =>
sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') -
sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT');
const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER';
const deposits = signed('PARTY_TRAN');
const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT');
const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT');
const interest =
signed('INTEREST_POST') +
signed('OD_INTEREST_POST') +
signed('PREMATURE_AGENT_TOP_UP') +
signed('PREMATURE_INT_RECOVERY');
const balance = Number(this.data.currentBalance || 0);
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
// still add up to the balance rather than leaving the officer a gap to explain.
const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100;
// Interest already worked out but not yet credited is posted by the payout itself, first.
const interestToPost = Number(this.data.interestCalc || 0);
return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost };
}
get canPayOut(): boolean {
if (this.loading) {
return false;
}
return this.destination === 'other' || !!this.selectedAccountNo;
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
payOut(): void {
this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {});
this.dialogRef.close(
this.destination === 'own'
? { toSavingsAccountNo: this.selectedAccountNo }
: { sundryGlAccountId: this.sundryGlAccountId }
);
}
}
@@ -47,8 +47,8 @@
<div class="pcd-choice-detail">
Interest recalculated at
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
back before the balance is paid out.
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
before the balance is paid out.
</div>
} @else {
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
@@ -61,8 +61,8 @@
<div class="pcd-choice-body">
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
<div class="pcd-choice-detail">
The customer keeps a full month's interest, and the agent funds the days left in this month. The
balance is paid out in full.
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
paid out in full.
</div>
</div>
</label>
@@ -86,7 +86,7 @@
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
Into another account of theirs at this bank
</mat-radio-button>
<mat-radio-button value="other">Park it for transfer to another bank</mat-radio-button>
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
</mat-radio-group>
@if (destination === 'own' && ownAccounts.length > 0) {
@@ -100,19 +100,76 @@
</mat-form-field>
}
@if (destination === 'other') {
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (ownAccounts.length === 0 && !loading) {
<p class="pcd-note">
This customer has no other account here, so the balance is parked in matured-deposits payable for the
branch to send on.
This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their
name until it is paid to them.
</p>
}
<p class="pcd-warn">This closes the account. It cannot be reopened.</p>
<!--
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
-->
@if (withinLockin) {
<div class="pcd-lockin">
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
<p class="pcd-note">
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
</p>
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Reason for closing within lock-in</mat-label>
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
</mat-form-field>
</div>
}
<p class="pcd-warn">
@if (withinLockin) {
Once approved, this closes the account. It cannot be reopened.
} @else {
This closes the account. It cannot be reopened.
}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
Close Account
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
</button>
</mat-dialog-actions>
@@ -110,3 +110,23 @@
font-size: 0.8rem;
font-weight: 500;
}
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
.pcd-lockin {
margin-top: 18px;
padding: 12px 14px 4px;
border-radius: 8px;
border: 1px solid rgb(180 83 9 / 30%);
background: rgb(245 158 11 / 8%);
}
.pcd-lockin-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #b45309;
}
.po-hint-warn {
color: #c62828;
}
@@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@@ -51,6 +52,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
@@ -73,6 +75,61 @@ export class PrematureCloseDialogComponent implements OnInit {
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
@@ -84,6 +141,12 @@ export class PrematureCloseDialogComponent implements OnInit {
scheme: any = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
@@ -120,17 +183,46 @@ export class PrematureCloseDialogComponent implements OnInit {
return Math.max(0, rate - reduction);
}
/** Why the lock-in should be waived for this account - required when it is inside it. */
lockinReason = '';
/**
* The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it
* the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason,
* for a manager to approve - the scheme's lock-in itself is never changed to let one account through.
*/
get closableFrom(): Date | null {
const months = Number(this.scheme?.prematureLockinMonths || 0);
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
if (months <= 0 || !opened || isNaN(opened.getTime())) {
return null;
}
const closable = new Date(opened);
closable.setMonth(closable.getMonth() + months);
return closable;
}
get withinLockin(): boolean {
const closable = this.closableFrom;
return !!closable && closable.getTime() > Date.now();
}
get canClose(): boolean {
if (this.loading || !this.borneBy) {
return false;
}
return this.destination === 'other' || !!this.selectedAccountNo;
if (this.withinLockin && !this.lockinReason.trim()) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
close(): void {
this.dialogRef.close({
shortfallBorneBy: this.borneBy,
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {})
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}),
...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}),
...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {})
});
}
}
@@ -43,7 +43,10 @@
<div class="pl-kpi-l">Active Agents</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal" matTooltip="Money collected from parties that agents have not yet handed over to a branch">
<div
class="pl-kpi pl-kpi--teal"
matTooltip="Money collected from parties that agents have not yet handed over to a branch"
>
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
@@ -92,6 +95,18 @@
<!-- Which branch the agent's collections belong to. Its own column rather than a subtitle:
it decides which branch's batch run picks the agent up, so it has to be scannable. -->
<!-- The GL the cashier takes this agent's cash into, so it is to hand without opening the agent. -->
<ng-container matColumnDef="glAccount">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let a">
@if (a.glAccountCode) {
<span class="pl-tag">{{ a.glAccountCode }} · {{ a.glAccountName }}</span>
} @else {
<span class="am-no-branch">Not set</span>
}
</td>
</ng-container>
<ng-container matColumnDef="homeBranch">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Branch</th>
<td mat-cell *matCellDef="let a">
@@ -29,6 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { officesUnder } from './offices-under';
/**
* Agent Master list.
@@ -61,11 +65,13 @@ import { OrganizationService } from 'app/organization/organization.service';
})
export class AgentMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
displayedColumns: string[] = [
'agentCode',
'clientName',
'homeBranch',
'glAccount',
'agentCity',
'agentSavingAccountNo',
'agentCurrentBalance',
@@ -103,8 +109,21 @@ export class AgentMasterComponent implements OnInit {
}
private loadAgents(): void {
this.organizationService.getAgents().subscribe((agents: any[]) => {
this.agents = agents || [];
const loginOfficeId = this.authenticationService.getCredentials()?.officeId;
forkJoin({
agents: this.organizationService.getAgents(),
offices: this.organizationService.getOffices().pipe(catchError(() => of([])))
}).subscribe(({ agents, offices }: any) => {
// The branch logged in to sees its own agents and those of every branch under it - Head Office,
// at the top, sees them all.
const visible = new Set(
officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map(
(office: any) => office.id
)
);
this.agents = (agents || []).filter(
(agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId)
);
this.dataSource = new MatTableDataSource(this.agents);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
@@ -28,9 +28,45 @@
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
@if (clientLookupFailed) {
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
#clientInput
#clientTrigger="matAutocompleteTrigger"
(focus)="onClientFocus()"
placeholder="Click to see clients, or type a name or ID…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of clientOptions; track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
@if (clientListIsPartial) {
<mat-option disabled class="am-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (agentForm.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
} @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) {
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
} @else if (clientOtherBranch) {
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
} @else if (loadedClientName) {
<mat-hint>{{ loadedClientName }}</mat-hint>
@@ -72,8 +108,7 @@
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- Which branch the agent collects out of. Filled from the client's own office, and
changeable, since an agent can be on the books at one branch and work another. The
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The
option carries the office's id while showing its name, so renaming a branch later
leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
@@ -111,7 +146,7 @@
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
}
</mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
@@ -122,7 +157,9 @@
{{ loadedClientName }} has no savings account — commission can only be paid into one
</mat-hint>
} @else {
<mat-hint class="am-hint--error">Enter a Client ID first — types come from that client's own accounts</mat-hint>
<mat-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
@@ -148,17 +185,12 @@
<mat-datepicker #openingDatePicker></mat-datepicker>
</mat-form-field>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input
matInput
required
[max]="maxBirthDate"
[matDatepicker]="birthDatePicker"
formControlName="agentBirthDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</div>
</div>
@@ -179,10 +211,37 @@
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="guarantorAuto"
#guarantorInput
#guarantorTrigger="matAutocompleteTrigger"
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
placeholder="Click to see clients, or type…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of guarantorOptionsFor(guarantor); track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
</mat-autocomplete>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-error>An agent cannot be its own guarantor</mat-error>
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
@@ -244,7 +303,13 @@
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
<button
mat-raised-button
color="primary"
type="button"
[disabled]="agentForm.invalid || clientOtherBranch || !!clientAlreadyAgent || !!clientAgeProblem"
(click)="save()"
>
Save
</button>
</div>
@@ -144,3 +144,48 @@ $primary: #0f766e;
grid-template-columns: repeat(2, 1fr);
}
}
/* One row of the client search list: the id, the name, and the branch - as on the Party form. */
.am-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
.am-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.am-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: #0f766e;
}
.am-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
.am-search-ic {
margin-right: 8px;
color: rgb(0 0 0 / 40%);
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormGroup,
@@ -28,9 +28,14 @@ import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search';
import { officesUnder } from '../offices-under';
/** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
@@ -61,7 +66,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
imports: [
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective
RestrictInputDirective,
MatAutocompleteModule
]
})
export class CreateAgentComponent implements OnInit {
@@ -74,21 +80,69 @@ export class CreateAgentComponent implements OnInit {
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
private changeDetector = inject(ChangeDetectorRef);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
/**
* The latest date of birth the picker will offer: eighteen years ago today.
*
* It was previously today, which let a clerk save an agent born on the day they were entered —
* the picker opens on today, so a stray Enter was enough. The figure matches the rule the server
* applies, so the calendar cannot offer a date the save would then refuse.
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
* Branch list offers only it, and only its clients can be picked.
*/
maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate());
private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null;
/**
* The branch the agent is being opened for - the one picked in Home Branch, the login branch until
* then. Its clients are the ones offered, since an agent collects for the branch they belong to.
*/
private get branchOfficeId(): number | null {
return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId;
}
/** Set when the client keyed belongs to another branch, which cannot be made an agent here. */
clientOtherBranch = false;
/**
* The code of the agent the keyed client already is, if any. One client is one agent, so they are
* left out of the list and refused when typed.
*/
clientAlreadyAgent = '';
private agentCodeByClientId: Record<string, string> = {};
/** Why the picked client's age rules them out as an agent, or empty when it does not. */
clientAgeProblem = '';
/** The ages an agent is taken on at, matching the rule the server applies. */
private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60;
/** The agent's client list without the clients who are already agents. */
private notYetAgents(clients: any[]): any[] {
return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]);
}
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
loadedClientName = '';
clientLookupFailed = false;
/**
* Clients to pick from, as on the Party form: the first page when the field is focused, then
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id.
*/
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
/**
* The client's own open savings accounts, fetched once the client is resolved. The commission
* account is picked from these rather than typed, so it is always an account that exists and
@@ -103,6 +157,12 @@ export class CreateAgentComponent implements OnInit {
*/
savingsAccountTypes: string[] = [];
/**
* Each type's product code, which is what the dropdown shows. The type is still saved by its name,
* as before, so what other screens and reports read is unchanged.
*/
savingsAccountTypeCodes: Record<string, string> = {};
/** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */
private static readonly SAVINGS_DEPOSIT_TYPE = 100;
@@ -141,7 +201,11 @@ export class CreateAgentComponent implements OnInit {
agentForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
// A name typed to search with is not a client; only an id picked or keyed saves.
[
Validators.required,
Validators.pattern(/^\d+$/)
]
],
agentCode: [
{
@@ -186,10 +250,8 @@ export class CreateAgentComponent implements OnInit {
'',
Validators.required
],
agentBirthDate: [
'',
Validators.required
],
// Filled from the client, whose date of birth was checked when they were opened.
agentBirthDate: [''],
agentCurrentBalance: [
{
value: 0,
@@ -209,8 +271,39 @@ export class CreateAgentComponent implements OnInit {
});
ngOnInit(): void {
// A different branch has different clients: the list is read again, and a client already keyed
// is checked against the new branch.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
});
// Who is already an agent, so they are neither offered nor accepted as a new one.
this.organizationService.getAgents().subscribe({
next: (agents: any[]) => {
this.agentCodeByClientId = {};
for (const agent of agents || []) {
if (agent.active !== false && agent.clientId != null) {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
},
error: () => (this.agentCodeByClientId = {})
});
this.organizationService.getOffices().subscribe({
next: (offices: any) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []),
next: (offices: any) => {
const all = Array.isArray(offices) ? offices : offices?.pageItems || [];
// The branch logged in to and every branch under it - Head Office, at the top, sees them all.
this.offices = officesUnder(all, this.loginOfficeId);
if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) {
this.agentForm.get('officeId')?.setValue(this.loginOfficeId);
}
},
error: () => (this.offices = [])
});
@@ -220,7 +313,9 @@ export class CreateAgentComponent implements OnInit {
// The client may already have been keyed while this was in flight, so the list is rebuilt
// rather than left showing an account this now knows to exclude.
const clientId = this.agentForm.get('clientId')?.value;
if (clientId) { this.loadClientSavingsAccounts(String(clientId)); }
if (clientId) {
this.loadClientSavingsAccounts(String(clientId));
}
},
error: () => (this.ddsProductId = null)
});
@@ -245,11 +340,24 @@ export class CreateAgentComponent implements OnInit {
this.agentForm
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe(() => {
.subscribe((value) => {
const typed = String(value ?? '').trim();
// A name is something to search for, not a client yet - the fields stay until one is picked.
if (typed && !/^\d+$/.test(typed)) {
this.searchClients(typed);
return;
}
if (!typed) {
this.showClientList();
}
this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity();
// And the agent's own client leaves (or rejoins) each row's list.
if (!row.get('clientId')?.value) {
this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients));
}
}
});
}
@@ -264,7 +372,9 @@ export class CreateAgentComponent implements OnInit {
'',
[
Validators.required,
this.notTheAgentItself()
Validators.pattern(/^\d+$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
],
guarantorName: [
@@ -288,14 +398,103 @@ export class CreateAgentComponent implements OnInit {
});
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
// Searched by name like the agent's own client field; a picked or keyed id fills the row.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((clientId) => this.loadGuarantorClient(group, clientId));
.subscribe((value) => {
const typed = String(value ?? '').trim();
if (typed && !/^\d+$/.test(typed)) {
searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
});
return;
}
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed);
if (!typed) {
// Emptied: the list comes back rather than leaving nothing to pick from.
this.onGuarantorFocus(group);
}
// Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) {
if (row !== group) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
});
return group;
}
/**
* Each guarantor row's list lives on the row itself, as a plain array the template reads - the same
* way the agent's own list works - so one row's typing never fills another's.
*/
private setGuarantorOptions(group: AbstractControl, options: any[]): void {
(group as any).clientOptions = options;
}
guarantorOptionsFor(group: AbstractControl): any[] {
return (group as any).clientOptions || GUARANTOR_NO_OPTIONS;
}
/**
* Opens the client list on a guarantor row as soon as it is focused, as the agent's field does.
* The list is filled before the panel is asked to open, since a panel opened empty stays shut.
*/
onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
if (trigger) {
// Kept so a search answered later can open this row's list too.
(group as any).panel = { trigger, input };
}
if (group.get('clientId')?.value) {
return;
}
const show = () => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
};
if (this.initialClients.length > 0) {
show();
return;
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
show();
},
error: () => undefined
});
}
/** Clients who can stand guarantor on this row: not the agent, and not already on another row. */
private eligibleGuarantors(group: AbstractControl, found: any[]): any[] {
const agentClientId = String(this.agentForm.get('clientId')?.value ?? '');
const taken = new Set(
this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? ''))
);
return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id)));
}
/** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || !this.agentForm) {
return null;
}
const repeated = this.guarantors.controls.some(
(row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value)
);
return repeated ? { duplicateGuarantor: true } : null;
};
}
/**
* An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes
* from someone else.
@@ -312,7 +511,14 @@ export class CreateAgentComponent implements OnInit {
/** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */
private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void {
for (const controlName of ['guarantorName', 'telephone', 'address1', 'address2', 'city', 'pinCode']) {
for (const controlName of [
'guarantorName',
'telephone',
'address1',
'address2',
'city',
'pinCode'
]) {
group.get(controlName)?.setValue('');
}
if (!clientId) {
@@ -343,11 +549,18 @@ export class CreateAgentComponent implements OnInit {
}
addGuarantor(): void {
this.guarantors.push(this.buildGuarantorGroup());
const group = this.buildGuarantorGroup();
this.guarantors.push(group);
// Ready before the field is ever focused, so the list is there the moment it is clicked.
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
}
removeGuarantor(index: number): void {
this.guarantors.removeAt(index);
// A row that clashed with the removed one no longer does.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
/** The fields the legacy screen filled from the client, rather than asking the officer for. */
@@ -360,6 +573,60 @@ export class CreateAgentComponent implements OnInit {
'agentPinCode'
];
/** Opens the client list when the field is focused, unless a client is already in it. */
onClientFocus(): void {
if (this.agentId || this.agentForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the first page of clients, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.agentId) {
return;
}
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
return;
}
// The chosen branch's clients only, since an agent is opened for the branch they belong to.
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
},
// A failed list is not a failed screen - the id can still be typed.
error: () => (this.clientOptions = [])
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = this.notYetAgents(found).slice(0, 25);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
});
}
/**
* Shows a list that has just arrived. An autocomplete opened while its list was still empty stays
* shut when the options come in, so the screen is refreshed and the panel opened again - but only
* on the field still being typed in, never one the officer has moved on from.
*/
private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
this.changeDetector.detectChanges();
if (trigger && input && document.activeElement === input) {
trigger.openPanel();
}
}
/**
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
* They always show the client currently entered: changing the id replaces them, clearing it
@@ -370,6 +637,7 @@ export class CreateAgentComponent implements OnInit {
return;
}
this.clearClientDerivedFields();
this.agentForm.get('agentBirthDate')?.setValue('');
this.loadedClientName = '';
this.clientLookupFailed = false;
// A different client has different accounts, so anything picked for the previous one is stale.
@@ -378,22 +646,42 @@ export class CreateAgentComponent implements OnInit {
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
this.clientAgeProblem = '';
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
// One client is one agent: someone already on the books is not made a second agent.
this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || '';
if (this.clientAlreadyAgent) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
// An agent is opened at the branch chosen, from that branch's own clients.
if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) {
this.clientOtherBranch = true;
this.loadedClientName = '';
return;
}
this.loadedClientName = client?.displayName || '';
this.setField('agentNameId', client?.displayName);
this.setField('agentTelephone', client?.mobileNo);
// The branch the person is on the bank's books at. Offered as a starting point rather than
// forced, since an agent can be recorded at one office and collect out of another. Set as a
// number rather than through setField, whose String() would stop it matching an option.
if (client?.officeId) {
this.agentForm.get('officeId')?.setValue(Number(client.officeId));
}
// The client's own date of birth, already taken and checked when the client was opened -
// copied across rather than keyed again.
const birthDate = asDate(client?.dateOfBirth);
this.agentForm.get('agentBirthDate')?.setValue(birthDate);
// Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save.
const age = ageOn(birthDate, new Date());
this.clientAgeProblem =
age === null
? 'This client has no date of birth on record'
: age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE
? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old`
: '';
this.loadClientAddress(String(clientId));
this.loadClientSavingsAccounts(String(clientId));
},
@@ -429,6 +717,11 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypes = [
...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean))
];
for (const account of this.clientSavingsAccounts) {
if (account.productName && account.shortProductName) {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
}
}
},
error: () => {
this.clientSavingsAccounts = [];
@@ -539,10 +832,23 @@ export class CreateAgentComponent implements OnInit {
}))
};
const done = (response: any) => {
// Under maker-checker the command is only recorded, and nothing has been saved yet.
if (!!response?.commandId && response?.resourceId == null) {
this.alertService.alert({
type: 'Sent for Approval',
message: this.agentId
? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.`
: `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.`
});
}
this.exit();
};
if (this.agentId) {
this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit());
this.organizationService.updateAgent(this.agentId, payload).subscribe(done);
} else {
this.organizationService.createAgent(payload).subscribe(() => this.exit());
this.organizationService.createAgent(payload).subscribe(done);
}
}
@@ -591,3 +897,33 @@ export class CreateAgentComponent implements OnInit {
}
}
}
/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */
function asDate(value: any): Date | null {
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
if (value) {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */
const GUARANTOR_NO_OPTIONS: any[] = [];
/** Full years between a date of birth and a day, or null without a date of birth. */
function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) {
return null;
}
let age = on.getFullYear() - birthDate.getFullYear();
const birthdayPassed =
on.getMonth() > birthDate.getMonth() ||
(on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate());
if (!birthdayPassed) {
age--;
}
return age;
}
@@ -0,0 +1,27 @@
/**
* 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/.
*/
/**
* The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."):
* an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the
* top, has them all. Falls back to the office alone when its hierarchy is not known.
*/
export function officesUnder(offices: any[], officeId: number | null | undefined): any[] {
if (!officeId) {
return offices;
}
const own = offices.find((office: any) => office.id === officeId);
if (!own) {
return [];
}
const hierarchy = String(own.hierarchy || '');
if (!hierarchy) {
return [own];
}
return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy));
}
@@ -15,6 +15,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Commission Slab Master component.
@@ -144,9 +145,9 @@ export class CommissionSlabMasterComponent implements OnInit {
private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any {
return this.slabs.find(
(slab) =>
slab.slabType === candidate.slabType
&& Number(candidate.slabFrom) <= Number(slab.slabTo)
&& Number(candidate.slabTo) >= Number(slab.slabFrom)
slab.slabType === candidate.slabType &&
Number(candidate.slabFrom) <= Number(slab.slabTo) &&
Number(candidate.slabTo) >= Number(slab.slabFrom)
);
}
@@ -257,8 +258,12 @@ export class CommissionSlabMasterComponent implements OnInit {
locale: 'en'
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
next: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService);
} else {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
}
this.editingIndex = -1;
this.loadSlabs();
},
@@ -268,7 +273,9 @@ export class CommissionSlabMasterComponent implements OnInit {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab'
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not correct the slab'
});
}
});
@@ -298,11 +305,20 @@ export class CommissionSlabMasterComponent implements OnInit {
}));
this.organizationService.createCommissionSlabs(payload).subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
next: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`,
this.organizationService,
this.alertService
);
} else {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
}
this.loadSlabs();
},
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
@@ -311,7 +327,9 @@ export class CommissionSlabMasterComponent implements OnInit {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs'
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not save the slabs'
});
}
});
@@ -22,6 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
@@ -264,11 +265,11 @@ export class DdsBatchProcessingComponent implements OnInit {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. `
+ (isPosting
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ')
+ 'It cannot be undone from this screen.'
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
@@ -296,11 +297,21 @@ export class DdsBatchProcessingComponent implements OnInit {
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe(() => {
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
@@ -312,8 +323,9 @@ export class DdsBatchProcessingComponent implements OnInit {
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. `
+ 'It continues in the background; this list will update when it is done.'
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
@@ -339,6 +339,37 @@ export class OrganizationService {
* @param {string | number} clientId Client ID.
* @returns {Observable<any>} id, accountNo, productName and balance for each.
*/
/** The liability GL accounts a DDS balance can be parked in, for the clerk to search and pick. */
getDdsSundryAccounts(): Observable<any> {
return this.http.get('/dds/parties/sundryaccounts');
}
/** DDS entries waiting on (or already past) a checker - PENDING, APPROVED or REJECTED. */
getDdsApprovals(status: string, fromDate?: string, toDate?: string): Observable<any> {
const params: any = { status };
if (fromDate) {
params.fromDate = fromDate;
}
if (toDate) {
params.toDate = toDate;
}
return this.http.get('/dds/approvals', { params });
}
getDdsApproval(commandId: string | number): Observable<any> {
return this.http.get(`/dds/approvals/${commandId}`);
}
/** Approves or rejects a DDS entry; an approval returns the voucher it moved under. */
actOnDdsApproval(commandId: string | number, command: 'approve' | 'reject'): Observable<any> {
return this.http.post(`/dds/approvals/${commandId}`, {}, { params: { command } });
}
/** How a closed DDS account was closed: by whom, what it came to and where the money went. */
getPartyClosure(partyId: string | number): Observable<any> {
return this.http.get(`/dds/parties/${partyId}/closure`);
}
getCreditableAccounts(clientId: string | number): Observable<any> {
return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } });
}
@@ -376,6 +407,14 @@ export class OrganizationService {
return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand);
}
/**
* Closes a DDS account early inside its scheme's lock-in, for the reason given. Its own permission,
* so it goes to a manager for approval; the scheme's lock-in is not changed.
*/
prematureCloseOverrideParty(partyId: string | number, closeCommand: any): Observable<any> {
return this.http.post(`/dds/parties/${partyId}?command=prematurecloseoverride`, closeCommand);
}
/**
* @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post.
* @returns {Observable<any>}
@@ -404,6 +443,26 @@ export class OrganizationService {
return this.http.post('/dds/collectionentries/settle', settlement);
}
/** Everything the agent still has outstanding, whatever day it was written - what a receipt is applied against. */
getAgentPendingCollections(agentId: any): Observable<any> {
return this.http.get(`/dds/collectionentries/pending?agentId=${agentId}`);
}
/**
* The cashier's receipts into the agent's GL account: the DDS receipts ready to apply, or - with a
* voucher number - that one voucher in whatever state, so the screen can say why it cannot be used.
*/
getAgentCashReceipts(agentId: any, voucherNo?: string, includeWaiting = false): Observable<any> {
const voucher = voucherNo ? `&voucherNo=${encodeURIComponent(voucherNo)}` : '';
const waiting = includeWaiting ? '&includeWaiting=true' : '';
return this.http.get(`/dds/collectionentries/cashreceipts?agentId=${agentId}${voucher}${waiting}`);
}
/** A cashier receipt's own Dr/Cr lines. */
getCashReceiptGlEntries(receiptId: any): Observable<any> {
return this.http.get(`/dds/collectionentries/cashreceipts/${receiptId}/glentries`);
}
/**
* An agent's DDS transactions. The daily entry screen uses this to show what has already been
* keyed for the agent today, so the same collection book is not entered twice.
@@ -417,6 +476,16 @@ export class OrganizationService {
});
}
/**
* @param {string | number} partyId DDS account (party) id.
* @returns {Observable<any>} Everything posted to that DDS account, most recent first.
*/
getPartyTransactionsOfParty(partyId: string | number): Observable<any> {
return this.http.get('/dds/partytransactions', {
params: new HttpParams().set('partyId', String(partyId))
});
}
/**
* @returns {Observable<any>} DDS Commission Slabs data
*/
@@ -11,15 +11,15 @@
<div class="dat-head">
<div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<h2>Account Type Creation</h2>
<p>{{ isEdit ? 'Update this Daily Deposit scheme variant.' : 'Register a Daily Deposit scheme variant.' }}</p>
<h2>DDS Product Creation</h2>
<p>{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}</p>
</div>
</div>
<form [formGroup]="accountTypeForm">
<div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Account Type Group</mat-label>
<mat-label>Product Group</mat-label>
<mat-select required formControlName="accountTypeGroup">
@for (group of accountTypeGroups; track group) {
<mat-option [value]="group">{{ group }}</mat-option>
@@ -28,15 +28,24 @@
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field">
<mat-label>A/c.Type</mat-label>
<mat-label>Product Code</mat-label>
<input matInput required maxlength="20" formControlName="accountTypeCode" style="text-transform: uppercase" />
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field dat-field--wide">
<mat-label>A/c. Type Description</mat-label>
<mat-label>Product Description</mat-label>
<input matInput required maxlength="200" formControlName="accountTypeDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Interest Rate (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
<mat-hint>Interest is worked out daily on the balance (rate ÷ 365)</mat-hint>
@if (accountTypeForm.get('interestRate')?.hasError('required')) {
<mat-error>Interest rate is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Auto Interest Post</mat-label>
<mat-select required formControlName="autoInterestPost">
@@ -84,8 +93,8 @@
-->
<h3 class="dat-section">Premature Closure</h3>
<p class="dat-section-note">
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time
whether the customer or the agent bears the cost; these set what that costs.
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether
the customer or the agent bears the cost; these set what that costs.
</p>
<div class="dat-grid">
@@ -97,26 +106,36 @@
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Customer: interest rate reduced by (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.25" formControlName="prematureInterestReduction" />
<input
matInput
required
type="number"
min="0"
max="100"
step="0.25"
formControlName="prematureInterestReduction"
/>
<mat-hint>Taken off the rate for the whole period, not only the part left to run.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Agent: commission reduced by (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.25" formControlName="prematureAgentCommissionPenalty" />
<input
matInput
required
type="number"
min="0"
max="100"
step="0.25"
formControlName="prematureAgentCommissionPenalty"
/>
<mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint>
</mat-form-field>
</div>
<div class="dat-actions">
<button mat-raised-button type="button" (click)="cancel()">Cancel</button>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="accountTypeForm.invalid"
(click)="submit()"
>
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
<fa-icon icon="check" style="margin-right: 6px"></fa-icon>
{{ isEdit ? 'Update' : 'Submit' }}
</button>
@@ -16,7 +16,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Account Type Creation — the legacy DDS screen this replaces, field for field: group, code,
@@ -40,6 +42,7 @@ export class CreateDdsAccountTypeComponent implements OnInit {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
accountTypeGroups = ['DDS'];
@@ -85,6 +88,15 @@ export class CreateDdsAccountTypeComponent implements OnInit {
false,
Validators.required
],
// Mandatory: every account opened on this scheme takes this rate, rather than one typed per customer.
interestRate: [
'',
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
/*
* What this scheme charges when an account is closed before its term is up. Set here rather than
* asked of the clerk at closing time: the terms are the bank's, and the same for every account on
@@ -157,6 +169,7 @@ export class CreateDdsAccountTypeComponent implements OnInit {
autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly',
autoInterestPostDay: accountType.autoInterestPostDay ?? '',
maturityAutoClose: !!accountType.maturityAutoClose,
interestRate: accountType.interestRate ?? '',
// ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one
// that charges nothing - and must not be replaced by the default.
prematureLockinMonths: accountType.prematureLockinMonths ?? 12,
@@ -178,8 +191,10 @@ export class CreateDdsAccountTypeComponent implements OnInit {
const payload = {
...raw,
// The posting day only applies while auto-posting is on; send nothing for it otherwise.
autoInterestPostDay: raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined,
autoInterestPostDay:
raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined,
// Sent as numbers, not the strings a text input yields - the backend reads them as decimals.
interestRate: Number(raw.interestRate),
prematureLockinMonths: Number(raw.prematureLockinMonths),
prematureInterestReduction: Number(raw.prematureInterestReduction),
prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty),
@@ -187,13 +202,22 @@ export class CreateDdsAccountTypeComponent implements OnInit {
locale: this.settingsService.language.code
};
const done = () => {
this.alertService.alert({
type: 'Success',
message: this.isEdit
? `Account type ${payload.accountTypeCode} updated`
: `Account type ${payload.accountTypeCode} created`
});
const done = (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`DDS product ${payload.accountTypeCode} ${this.isEdit ? 'change' : ''}`.trim(),
this.organizationService,
this.alertService
);
} else {
this.alertService.alert({
type: 'Success',
message: this.isEdit
? `Account type ${payload.accountTypeCode} updated`
: `Account type ${payload.accountTypeCode} created`
});
}
this.backToList();
};
@@ -11,8 +11,8 @@
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div class="pl-header-text">
<h2>DDS Account Types</h2>
<p>Scheme variants a Daily Deposit account can be opened under.</p>
<h2>DDS Product</h2>
<p>The Daily Deposit products a customer account can be opened under.</p>
</div>
</div>
<div class="pl-header-right">
@@ -23,7 +23,7 @@
</div>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Account Type</span>
<span>Create DDS Product</span>
</button>
</div>
</div>
@@ -33,7 +33,7 @@
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCount | number }}</div>
<div class="pl-kpi-l">Total Account Types</div>
<div class="pl-kpi-l">Total DDS Products</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
@@ -52,7 +52,7 @@
<input
type="text"
(input)="applyFilter($any($event.target).value)"
placeholder="Search by code or description…"
placeholder="Search by product code or description…"
/>
</div>
</div>
@@ -64,14 +64,14 @@
<p>Create one so Daily Deposit accounts can be opened against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Account Type</span>
<span>Create DDS Product</span>
</button>
</div>
} @else {
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="accountTypeCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>A/c Type</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Product</th>
<td mat-cell *matCellDef="let t">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="calendar-day"></fa-icon></div>
+3 -3
View File
@@ -737,7 +737,7 @@ const routes: Routes = [
},
{
path: 'dds-account-types',
data: { title: 'DDS Account Types', breadcrumb: 'DDS Account Types' },
data: { title: 'DDS Product', breadcrumb: 'DDS Product' },
children: [
{
path: '',
@@ -746,12 +746,12 @@ const routes: Routes = [
{
path: 'create',
component: CreateDdsAccountTypeComponent,
data: { title: 'Create Account Type', breadcrumb: 'Create' }
data: { title: 'Create DDS Product', breadcrumb: 'Create' }
},
{
path: ':accountTypeId/edit',
component: CreateDdsAccountTypeComponent,
data: { title: 'Edit Account Type', breadcrumb: 'Edit', routeParamBreadcrumb: false }
data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }
}
]
},
+4 -2
View File
@@ -131,8 +131,10 @@
<i class="fas fa-calendar-day"></i>
</div>
<div class="pd-card__body">
<h3 class="pd-card__title">DDS Account Types</h3>
<p class="pd-card__desc">Create a Daily Deposit Scheme account type — group, code and auto-interest posting</p>
<h3 class="pd-card__title">DDS Product</h3>
<p class="pd-card__desc">
Create a Daily Deposit Scheme account type — group, code and auto-interest posting
</p>
</div>
<i class="fas fa-chevron-right pd-card__arrow"></i>
</a>
@@ -200,6 +200,10 @@
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span>
</a>
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>DDS Approvals</span>
</a>
<a
class="ac-tab"
routerLink="./chequebook-requests"
+6
View File
@@ -55,6 +55,7 @@ import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavi
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component';
/** Tasks Routes */
const routes: Routes = [
@@ -210,6 +211,11 @@ const routes: Routes = [
component: DisbursementRequestsComponent,
data: { title: 'Disbursement Requests' }
},
{
path: 'dds-approvals',
component: DdsApprovalsComponent,
data: { title: 'DDS Approvals' }
},
{
path: 'chequebook-requests',
component: ChequebookRequestsComponent,