feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view

- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 10:50:03 +05:30
co-authored by Claude Sonnet 5.5
parent 357f666421
commit 293b671966
45 changed files with 5973 additions and 1292 deletions
@@ -56,12 +56,13 @@
flex-shrink: 0;
}
// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue.
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
border-color: #0f766e !important;
background: #e6f4ee !important;
.option-check {
color: #2563eb;
color: #0f766e;
}
}
@@ -93,3 +94,76 @@
border-radius: 6px;
background: #f8fafc;
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// A step smaller than the sheet it shares, for this screen only: the shared sheet is sized in rem for
// Apply Additional Shares and is left alone, so the whole dialog is scaled here instead. Nothing in it
// is an overlay, so the scaling cannot throw a popup out of place.
.dialog-wrapper {
zoom: 0.88;
}
// ── The Daily Transaction screen's colours ───────────────────────────────────────────────────────────
// The shared sheet is indigo and blue, for Apply Additional Shares. This dialog opens from a screen
// drawn in green and teal, so it takes that palette here - the shared sheet is left as it is, and these
// rules only state what differs from it.
$page-teal: #0f766e;
$page-dark: #0f4f3c;
$page-soft: #e6f4ee;
$page-border: #b7ddd0;
.dialog-header {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 70%, #1f7a5c 100%);
}
.sc-card {
border-color: $page-border;
}
.slot-step {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 100%);
}
.slot-search {
border-color: $page-border;
background: $page-soft;
color: $page-teal;
&:hover {
background: $page-teal;
border-color: $page-teal;
color: #fff;
box-shadow: 0 2px 8px rgb(15 118 110 / 25%);
}
}
.option {
&:hover {
border-color: $page-teal;
background: $page-soft;
box-shadow: 0 2px 6px rgb(15 118 110 / 10%);
}
&:hover .option-radio {
border-color: $page-teal;
}
}
.option-info:hover {
color: $page-teal;
border-color: $page-teal;
background: $page-soft;
}
.gl-table tbody tr:hover {
background: $page-soft;
}
.dialog-actions [mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $page-teal 0%, $page-dark 100%) !important;
box-shadow: 0 2px 8px rgb(15 118 110 / 28%) !important;
&:hover {
box-shadow: 0 4px 14px rgb(15 118 110 / 40%) !important;
}
}
@@ -0,0 +1,78 @@
<!--
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="cd">
<div class="cd-head">
<span class="cd-head__ic"><i class="fas fa-list"></i></span>
<div class="cd-head__text">
<h2>Collections up to {{ data.dateText }}</h2>
<p>{{ data.agentName }} - written down and not yet handed in</p>
</div>
<button mat-stroked-button type="button" class="cd-refresh" (click)="refresh()">
<i class="fas fa-sync"></i> Refresh
</button>
<button type="button" class="cd-close" (click)="close()" aria-label="Close"><i class="fas fa-times"></i></button>
</div>
<div class="cd-body">
@if (data.loading()) {
<p class="cd-empty">Reading this agent's entries...</p>
} @else if (entries.length === 0) {
<p class="cd-empty">{{ data.agentName }} has nothing outstanding up to this date.</p>
} @else {
<div class="cd-table">
<div class="cd-row cd-row--head">
<span class="cd-date">Date</span>
<span class="cd-code">Party</span>
<span class="cd-name">Name</span>
<span class="cd-mode">Mode</span>
<span class="cd-amount">Amount</span>
</div>
@for (entry of pageEntries; track entry.id) {
<!-- Clicking a line closes this and shows that party on the screen. -->
<div class="cd-row" (click)="pick(entry)" title="Show this party's account">
<span class="cd-date">{{ entry.entryDate | dateFormat }}</span>
<span class="cd-code">{{ entry.partyCode }}</span>
<span class="cd-name">{{ entry.partyName }}</span>
<span class="cd-mode"
><span class="cd-chip">{{ entry.transactionMode }}</span>
@if (entry.advanceDays) {
<small>{{ entry.advanceDays }}d advance</small>
}
@if (entry.arrearsDays) {
<small>{{ entry.arrearsDays }}d previous</small>
}
</span>
<span class="cd-amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="cd-total">
<span>{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ data.total() | number: '1.2-2' }}</strong>
</div>
<p class="cd-note">
The cash and its receipts are under Agent Tran. Click a line to see that customer's account.
</p>
}
</div>
@if (entries.length > 0) {
<mat-paginator
class="cd-paginator"
[length]="entries.length"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPage($event)"
showFirstLastButtons
></mat-paginator>
}
</div>
@@ -0,0 +1,235 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
:host {
display: block;
}
// Small type throughout: this is a list to read down, and a page of it should fit the window.
.cd {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12.5px;
color: $text-dark;
}
.cd-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border;
&__ic {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border-radius: 9px;
background: $primary-light;
color: $primary-dark;
font-size: 13px;
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
p {
margin: 1px 0 0;
font-size: 11.5px;
color: $text-mid;
}
}
}
.cd-refresh {
height: 30px;
padding: 0 12px;
font-size: 12px;
color: $primary;
}
.cd-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: $text-mid;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.cd-body {
flex: 1;
min-height: 0;
padding: 10px 16px 6px;
overflow-y: auto;
}
.cd-empty {
margin: 8px 0;
padding: 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: $text-mid;
text-align: center;
}
.cd-table {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.cd-row {
display: grid;
grid-template-columns: 112px 78px minmax(0, 1fr) 120px 92px;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
cursor: pointer;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #f0fdf9;
}
&--head {
cursor: default;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
&:hover {
background: #f1f5f9;
}
}
}
.cd-date {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
.cd-code {
font-weight: 700;
color: $primary-dark;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
}
.cd-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-mode {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
small {
font-size: 10.5px;
color: $text-mid;
}
}
.cd-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cd-amount {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 12px;
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
background: $primary-light;
color: $text-mid;
strong {
font-size: 15px;
color: $primary-dark;
font-variant-numeric: tabular-nums;
}
}
.cd-note {
margin: 8px 2px 4px;
font-size: 11.5px;
color: $text-soft;
}
.cd-paginator {
flex: 0 0 auto;
border-top: 1px solid $border;
font-size: 12px;
--mat-paginator-container-text-size: 12px;
--mat-paginator-container-size: 44px;
--mat-paginator-form-field-container-height: 30px;
--mat-paginator-form-field-container-vertical-padding: 3px;
}
@media (width <= 640px) {
.cd-row {
grid-template-columns: 90px 70px minmax(0, 1fr) 80px;
.cd-mode {
display: none;
}
}
}
@@ -0,0 +1,93 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* What the dialog is given. The entries are read through functions rather than copied in, so a Refresh
* on the screen behind - which reloads the list - shows here as soon as it arrives.
*/
export interface CollectionsDialogData {
agentName: string;
/** The day the list runs up to, already worded for a reader. */
dateText: string;
entries: () => any[];
total: () => number;
loading: () => boolean;
refresh: () => void;
}
/**
* Everything the agent wrote down and has not handed in yet, up to the chosen day, customer by
* customer - the list the clerk counts a bag of cash against.
*
* It lives in a window, paged, rather than down the page: a round can run to hundreds of lines, and
* the screen behind it has a good deal more to show. A line can be clicked to see that customer's
* account on the screen; the dialog closes with the entry picked.
*/
@Component({
selector: 'mifosx-dds-collections-dialog',
standalone: true,
templateUrl: './collections-dialog.component.html',
styleUrls: ['./collections-dialog.component.scss'],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatPaginatorModule,
DateFormatPipe
]
})
export class CollectionsDialogComponent {
readonly data = inject<CollectionsDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<CollectionsDialogComponent>);
readonly pageSizeOptions = [
10,
25,
50,
100
];
pageIndex = 0;
pageSize = 10;
get entries(): any[] {
return this.data.entries() || [];
}
/** The lines on the page being looked at. */
get pageEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPage(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
}
refresh(): void {
// A reloaded list may be shorter than the page being looked at.
this.pageIndex = 0;
this.data.refresh();
}
/** Closes with the line clicked, for the screen to show whose account it is. */
pick(entry: any): void {
this.dialogRef.close(entry);
}
close(): void {
this.dialogRef.close();
}
}
@@ -11,14 +11,20 @@
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
<div class="dte-header__icon"><i class="fas fa-coins"></i></div>
<div class="dte-header__text">
<h2 class="dte-header__title">Daily Transaction Detail</h2>
<p class="dte-header__sub">
An agent's round - what was collected, the cash handed in and applied - and transfers between accounts.
</p>
</div>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<div class="dte-info-panel__title"><i class="fas fa-user-tie"></i>Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
@@ -28,17 +34,22 @@
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
<dd>{{ guarantor }}</dd>
} @empty {
<dt>Guarantor</dt>
<dd>—</dd>
}
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
<!-- Wider than the agent's card, with its details two pairs to a line, so a party's account is read without scrolling. -->
<div class="dte-info-panel dte-info-panel--party">
<div class="dte-info-panel__title"><i class="fas fa-user"></i>Party Information</div>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
@@ -79,15 +90,62 @@
</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
<i class="fas fa-info-circle"></i>
Click an entry in the collections list below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<!-- The round at a glance, once an agent is picked: nothing here is typed or saved. -->
@if (agentInfo.name) {
<div class="dte-kpis">
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-users"></i>Parties</span>
<strong class="dte-kpi__value">{{ agentParties.length }}</strong>
<span class="dte-kpi__hint">to collect from</span>
</div>
@if (isPartyTran) {
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-list-ol"></i>Entries</span>
<strong class="dte-kpi__value">{{ pendingEntries.length }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--accent">
<span class="dte-kpi__label"><i class="fas fa-rupee-sign"></i>To count</span>
<strong class="dte-kpi__value">{{ pendingTotal | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">the agent should hand over</span>
</div>
}
<!-- Agent Tran: what the agent owes on everything written down, what the cashier has taken in, and what is left. -->
@if (isAgentTran) {
<div class="dte-kpi dte-kpi--owed">
<span class="dte-kpi__label"><i class="fas fa-file-invoice"></i>To take</span>
<strong class="dte-kpi__value">{{ agentOwed | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--received">
<span class="dte-kpi__label"><i class="fas fa-check-circle"></i>Received (approved)</span>
<strong class="dte-kpi__value">{{ agentReceived | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">taken in by the cashier</span>
</div>
<div class="dte-kpi" [class.dte-kpi--due]="agentBalance > 0">
<span class="dte-kpi__label"><i class="fas fa-hourglass-half"></i>Still to take</span>
<strong class="dte-kpi__value">{{ agentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">left to receive</span>
</div>
}
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-wallet"></i>Agent Balance</span>
<strong class="dte-kpi__value">{{ agentInfo.currentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">held by {{ agentInfo.name }}</span>
</div>
</div>
}
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
@@ -112,7 +170,7 @@
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
@@ -127,7 +185,7 @@
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
@@ -140,100 +198,43 @@
}
</mat-select>
</mat-form-field>
</div>
<!--
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<!-- Agent Tran: the cashier's receipts are applied to the customers from here - the dialog shows the days and receipts. -->
@if (isAgentTran && agentInfo.name) {
<div class="dte-agent-actions">
<button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<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 }}
@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 class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
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 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>
}
<!--
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'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()"
>
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
}
<!-- The agent's written-down round, in a window of its own and paged. -->
@if (isPartyTran && agentInfo.name) {
<button mat-stroked-button type="button" class="dte-view-btn" (click)="openCollections()">
<i class="fas fa-list"></i> View Collections
<span class="dte-view-btn__count">{{ pendingEntries.length }}</span>
</button>
}
</div>
<!--
What the agent wrote down on their round, waiting to be counted, is no longer listed down this
page - a long round pushed everything else out of sight. It opens from View Collections above, in a
window of its own and paged, and a line clicked there shows that customer in Party Information.
-->
<!--
The agent's money used to have a section of its own down the page: what they owe on everything
written down, what the cashier has taken in, what is left, then the outstanding days and the
cashier's receipts. Apply DDS Receipt shows the same days and receipts, so the section is gone -
the three figures sit in the summary above, the button is beside the Date, and what is below is
only what the dialog does not say: which GL the cash goes into and anything that looks wrong.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-notes">
@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
@@ -246,21 +247,6 @@
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
@@ -273,66 +259,6 @@
{{ 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>
}
@@ -342,84 +268,102 @@
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<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" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<div class="dte-grid dte-grid--transfer">
<!-- The account the money goes into. -->
<div class="dte-party-card dte-party-card--to">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<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" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<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" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<!-- The account the money is taken from. -->
<div class="dte-party-card dte-party-card--from">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<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" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,7 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
/** Custom Components */
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component';
import {
ApplyDdsReceiptDialogComponent,
EntryDay,
@@ -152,6 +153,8 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
// Every guarantor the agent has, by name - the panel lists as many as there are rather than two fixed lines.
guarantors: [] as string[],
currentBalance: 0
};
@@ -505,6 +508,34 @@ export class DailyTransactionEntryComponent implements OnInit {
* 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.
*/
/**
* Opens what the agent wrote down up to the chosen day, in a window of its own and paged - it used to
* run down the page, where a long round pushed everything else out of sight. A line clicked there
* shows that customer in the Party Information panel.
*/
openCollections(): void {
this.dialog
.open(CollectionsDialogComponent, {
width: '820px',
maxWidth: '96vw',
autoFocus: false,
data: {
agentName: this.agentInfo.name,
dateText: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'dd MMMM yyyy'),
entries: () => this.pendingEntries,
total: () => this.pendingTotal,
loading: () => this.pendingLoading,
refresh: () => this.loadPendingEntries()
}
})
.afterClosed()
.subscribe((entry: any) => {
if (entry) {
this.showPartyOfEntry(entry);
}
});
}
openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
@@ -513,7 +544,7 @@ export class DailyTransactionEntryComponent implements OnInit {
const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog
.open(ApplyDdsReceiptDialogComponent, {
width: '780px',
width: '700px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
@@ -799,6 +830,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name),
currentBalance: agent.agentCurrentBalance || 0
};
this.agentGlCode = agent.glAccountCode || null;
@@ -813,6 +845,7 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
guarantors: [],
currentBalance: 0
};
}
@@ -890,11 +923,29 @@ export class DailyTransactionEntryComponent implements OnInit {
row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false });
}
/**
* The entry in the collections list the Party Information panel is showing, if one was clicked. The
* agent writes the round down on their own screen now, so there is no row here to key a party into -
* the panel is filled from the line the clerk is looking at instead.
*/
selectedEntry: any = null;
/** Shows whose account a written-down entry is, and what its balance becomes with the entry on it. */
showPartyOfEntry(entry: any): void {
this.selectedEntry = entry;
this.activeRow = -1;
this.partyInfo = this.agentParties.find((party) => String(party.partyCode) === String(entry?.partyCode)) || null;
}
/** The balance the party panel shows - live against whatever the active row currently holds. */
get partyBalanceAfter(): number {
if (!this.partyInfo || this.activeRow < 0) {
if (!this.partyInfo) {
return 0;
}
if (this.activeRow < 0) {
// No keyed row: it is the entry picked from the list that is being added to the balance.
return Number(this.partyInfo.currentBalance || 0) + Number(this.selectedEntry?.amount || 0);
}
const row = this.collections.at(this.activeRow);
return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0);
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.dcd-field {
width: 100%;
}
@@ -40,3 +42,34 @@
color: rgb(0 0 0 / 60%);
}
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// The same step smaller as the rest of the screen it opens from.
:host {
--mat-form-field-container-height: 38px;
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-text-size: 13px;
--mat-form-field-container-text-line-height: 20px;
--mat-form-field-outlined-label-text-populated-size: 11px;
--mat-form-field-subscript-text-size: 11px;
--mat-select-trigger-text-size: 13px;
font-size: 13px;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
h2[mat-dialog-title] {
font-size: 15px;
}
.dcd-amount {
padding: 8px 12px;
&__figure {
font-size: 1.1rem;
}
&__basis {
font-size: 0.74rem;
}
}
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.nd-head {
display: flex;
align-items: center;
@@ -42,3 +44,22 @@
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
.nd-head {
padding: 14px 20px 2px;
&__ic {
width: 30px;
height: 30px;
}
&__title {
font-size: 0.95rem;
}
}
.nd-message {
font-size: 0.82rem;
line-height: 1.5;
}
@@ -19,72 +19,487 @@
<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 class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
@if (isProductReview) {
<!--
A DDS product request reads as the product it creates or changes: who and what at the top,
the four terms a checker looks at first, then every term grouped as the Account Type screen
asks for them. An edit shows each changed term as old → new.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New product'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
<div class="ddr-hero__code">
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
@if (valueOf('accountTypeGroup')) {
<span class="ddr-dot"></span>
<span>{{ valueOf('accountTypeGroup') }} group</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="dri-meta-item">
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</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 class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</div>
</div>
</section>
<!-- 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') : '—' }}
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
<span class="ddr-stat__hint">p.a., worked out daily</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
<span class="ddr-stat__value">
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
</span>
<span class="ddr-stat__hint">
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
</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 class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
<span class="ddr-stat__hint">before early closure</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 class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
<span class="ddr-stat__value">{{
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
}}</span>
<span class="ddr-stat__hint">on the maturity date</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>
</section>
@if (compareFields) {
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
@if (changedCount) {
<span>
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
below as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the product as it stands.</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>
}
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
@if (group.changed) {
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
<span class="ddr-tile__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
@if (field.changed) {
<span class="ddr-tile__diff">
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
<mat-icon>arrow_forward</mat-icon>
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
</span>
} @else {
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
}
</div>
}
</div>
</section>
}
<ng-template #valueTpl let-field="field">
@if (field.format === 'yesno' && field.value) {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
field.value || '—'
}}</span>
}
</ng-template>
} @else if (isAgentReview) {
<!--
An agent request reads as the agent it opens or changes: who and when at the top, then the
client, address, branch and accounts - the branch, the ledger account and the client by name,
not number - and the guarantors named with it. An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('clientId')) {
<span>Client {{ valueOf('clientId') }}</span>
}
@if (valueOf('agentSavingAccountNo')) {
<span class="ddr-dot"></span>
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
field.value || '—'
}}</span>
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
<span class="ddr-section__title">Guarantors</span>
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
</header>
@if (guarantors.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Client</th>
<th>Name</th>
<th>Telephone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (g of guarantors; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ g.client || '—' }}</td>
<td>{{ g.name || '—' }}</td>
<td class="cell-mono">{{ g.telephone || '—' }}</td>
<td>{{ g.address || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No guarantors named in this request.</span>
</div>
}
</section>
} @else if (isPartyReview) {
<!--
A DDS account request reads as the account it opens or changes: who it is for and when, the
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New DDS account'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('subType')) {
<span>{{ valueOf('subType') }}</span>
}
@if (valueOf('clientId')) {
<span class="ddr-dot"></span>
<span>Client {{ valueOf('clientId') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
<span class="ddr-stat__hint">collected every day</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
<span class="ddr-stat__hint">{{
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
}}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
@if (field.format === 'yesno') {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
<span class="ddr-section__title">Nominees</span>
@if (detail?.nomineeSplit && nominees.length) {
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
}
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
</header>
@if (nominees.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Guardian</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (n of nominees; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ n.name || '—' }}</td>
<td>{{ n.relation || '—' }}</td>
<td>{{ n.gender || '—' }}</td>
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
<td>{{ n.birthDate || '—' }}</td>
<td>{{ n.guardianName || '—' }}</td>
<td class="num">{{ n.share || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No nominee named in this request.</span>
</div>
}
</section>
} @else {
<!-- 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">
<!-- No voucher slot for a change that moves no money - it would only ever read "—". -->
@if (voucherNo || movesMoney) {
<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>
</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">
<!-- Amount and sides only for an entry that moves money; a product or agent change has neither. -->
@if (movesMoney) {
<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) {
@@ -225,7 +640,7 @@
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
@@ -0,0 +1,453 @@
/**
* 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 DDS product review. Layered on the Disbursement & Transfers sheet and drawn from the same
// palette, so it reads as part of the same Approval Center rather than a screen of its own.
$brand: #0f4f3c;
$brand-mid: #1f7a5c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$warning: #b45309;
$warning-bg: #fffbeb;
$warning-border: #fde68a;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-white: #fff;
$bg-soft: #f8fafc;
$radius: 14px;
$shadow-sm:
0 1px 3px rgb(15 23 42 / 6%),
0 1px 2px rgb(15 23 42 / 4%);
@mixin icon($size) {
font-size: $size;
width: $size;
height: $size;
line-height: $size;
}
.ddr-mono {
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
letter-spacing: 0.02em;
}
// A product review has more to lay out than a money movement, so the dialog widens for it.
.dri-body.ddr-body {
max-width: none;
gap: 16px;
padding-bottom: 0;
}
.dri--dialog:has(.ddr-body) {
width: min(880px, 94vw);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
.ddr-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 22px 24px;
border-radius: $radius;
background:
radial-gradient(circle at 100% 0%, rgb(255 255 255 / 12%) 0, transparent 45%),
linear-gradient(135deg, $brand 0%, $brand-mid 100%);
color: #fff;
box-shadow: 0 8px 24px rgb(15 79 60 / 22%);
&__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
mat-icon {
@include icon(28px);
}
}
&__main {
min-width: 0;
}
&__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
&__code {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
color: rgb(255 255 255 / 78%);
}
&__meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 18px;
border-left: 1px solid rgb(255 255 255 / 18%);
}
// Status reads on the dark band as a light chip, whatever its colour on a white card.
.dri-status {
background: rgb(255 255 255 / 92%);
font-size: 11px;
padding: 3px 10px;
}
}
.ddr-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentcolor;
opacity: 0.6;
}
.ddr-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
mat-icon {
@include icon(18px);
opacity: 0.75;
}
b {
display: block;
font-weight: 600;
white-space: nowrap;
}
small {
display: block;
font-size: 11px;
color: rgb(255 255 255 / 65%);
}
}
.ddr-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgb(255 255 255 / 16%);
color: #fff;
border: 1px solid rgb(255 255 255 / 28%);
&--edit {
background: $warning-bg;
color: $warning;
border-color: $warning-border;
}
}
// ── Key terms ────────────────────────────────────────────────────────────────
.ddr-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.ddr-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: $bg-white;
border: 1px solid $border;
border-radius: 12px;
box-shadow: $shadow-sm;
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
mat-icon {
@include icon(16px);
color: $brand-mid;
}
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
overflow-wrap: anywhere;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
min-height: 14px;
}
// A value that is a name rather than a figure - an agent's code and name - reads smaller.
&__value--small {
font-size: 14px;
line-height: 1.3;
}
&--accent {
background: $brand-light;
border-color: #b7ddd0;
.ddr-stat__value {
font-size: 26px;
color: $brand;
}
}
}
// ── Edit banner ──────────────────────────────────────────────────────────────
.ddr-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
mat-icon {
@include icon(20px);
flex-shrink: 0;
}
&--none {
background: $bg-soft;
border-color: $border;
color: $text-mid;
}
}
// ── Sections ─────────────────────────────────────────────────────────────────
.ddr-section {
background: $bg-white;
border: 1px solid $border;
border-radius: $radius;
box-shadow: $shadow-sm;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, $bg-soft 0%, $bg-white 100%);
}
&__icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: $brand-light;
color: $brand;
mat-icon {
@include icon(17px);
}
}
&__title {
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-mid;
margin-right: auto;
}
}
.ddr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid $border;
min-width: 0;
// Two columns: a hairline between them, and none under the last row.
&:nth-child(odd) {
border-right: 1px solid $border;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text-dark;
overflow-wrap: anywhere;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: $warning-bg;
color: $warning;
border: 1px solid $warning-border;
}
&__diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
mat-icon {
@include icon(16px);
color: $warning;
}
}
&__old {
font-size: 14px;
color: $text-soft;
text-decoration: line-through;
}
&--changed {
background: linear-gradient(90deg, rgb(253 230 138 / 22%) 0%, $bg-white 85%);
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.ddr-pill {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
padding: 3px 10px 3px 7px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
mat-icon {
@include icon(16px);
}
&--yes {
background: #dcfce7;
color: #15803d;
}
}
// ── Actions stay in reach however far the terms run ─────────────────────────
.ddr-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 4px -20px 0;
padding: 14px 20px;
background: rgb(255 255 255 / 94%);
backdrop-filter: blur(6px);
border-top: 1px solid $border;
box-shadow: 0 -6px 16px rgb(15 23 42 / 6%);
}
// ── Narrow screens ───────────────────────────────────────────────────────────
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 720px) {
.ddr-hero {
grid-template-columns: auto 1fr;
&__meta {
grid-column: 1 / -1;
flex-flow: row wrap;
gap: 14px;
padding: 12px 0 0;
border-left: 0;
border-top: 1px solid rgb(255 255 255 / 18%);
}
}
.ddr-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-grid {
grid-template-columns: 1fr;
}
.ddr-tile {
&:nth-child(odd) {
border-right: 0;
}
&:nth-last-child(2):nth-child(odd) {
border-bottom: 1px solid $border;
}
}
}
@@ -25,7 +25,10 @@ import { OrganizationService } from 'app/organization/organization.service';
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
styleUrls: [
'../../../clerk/disbursement-review/disbursement-review.component.scss',
'./dds-approval-review.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -76,6 +79,99 @@ export class DdsApprovalReviewComponent implements OnInit {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
/** A product change's terms, field by field - empty for an entry that moves money. */
get fields(): any[] {
return this.detail?.fields || [];
}
/** An edit still waiting is shown against the product as it stands today. */
get compareFields(): boolean {
return this.fields.some((field: any) => 'current' in field);
}
/** A DDS product request, which is laid out as the product rather than as a money movement. */
get isProductReview(): boolean {
return this.entry?.entity === 'DDSACCOUNTTYPE' && this.fields.length > 0;
}
/** A DDS agent request, laid out as the agent and their guarantors rather than as a money movement. */
get isAgentReview(): boolean {
return this.entry?.entity === 'AGENT' && this.fields.length > 0;
}
/** A DDS account request, laid out as the account and its nominees rather than as a money movement. */
get isPartyReview(): boolean {
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
}
/** The nominees a DDS account request names. */
get nominees(): any[] {
return this.detail?.nominees || [];
}
/** The guarantors an agent request names. */
get guarantors(): any[] {
return this.detail?.guarantors || [];
}
get isEdit(): boolean {
return this.entry?.action === 'UPDATE';
}
/** A term's value as it will stand once approved, by its request parameter name. */
valueOf(key: string): string {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
private static readonly GROUP_ICONS: Record<string, string> = {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
'Scheme Terms': 'event_note',
'Balances & Status': 'toggle_on',
'Maturity Payout': 'currency_exchange',
Agent: 'badge',
'Address & Contact': 'location_on',
'Branch & Accounts': 'account_balance',
Balances: 'balance'
};
/** The terms in the sections the server groups them into, in the order it sends them. */
get fieldGroups(): { name: string; icon: string; changed: number; fields: any[] }[] {
if (this.groupsFor !== this.fields) {
const groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
for (const field of this.fields) {
const name = field.group || 'Details';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, icon: DdsApprovalReviewComponent.GROUP_ICONS[name] || 'tune', changed: 0, fields: [] };
groups.push(group);
}
group.fields.push(field);
group.changed += field.changed ? 1 : 0;
}
this.groupsFor = this.fields;
this.groups = groups;
}
return this.groups;
}
/** Built once per load rather than on every change detection pass. */
private groupsFor: any[] | null = null;
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
@@ -21,6 +21,7 @@ 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';
import { isDdsRoutedRequest } from 'app/tasks/product-entities';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
@@ -104,7 +105,11 @@ export class DdsApprovalsComponent implements OnInit {
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
// Pending DDS product and account requests wait under Product Approval and Pending Account
// instead; once actioned they show here again, so the DDS history stays in one place.
this.entries = (Array.isArray(rows) ? rows : []).filter(
(row: any) => this.filterStatus !== 'PENDING' || !isDdsRoutedRequest(row?.entity, row?.action)
);
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
@@ -11,7 +11,17 @@
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
<i class="fas fa-arrow-left"></i> Party Master
</button>
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
<div class="pm-header__icon"><i class="fas fa-users"></i></div>
<div class="pm-header__text">
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
<p class="pm-header__sub">
{{
partyId
? 'Update this daily deposit party.'
: 'Open a daily deposit party: who it is for, the scheme it runs under and the agent who collects.'
}}
</p>
</div>
@if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span>
}
@@ -20,7 +30,13 @@
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab label="Main Detail">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card pm-tab-icon"></i>Customer &amp; Scheme
@if (tabHasError('customer')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
@@ -38,35 +54,27 @@
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
(focus)="onClientFocus()"
placeholder="Click to see customers, or type a name or ID…"
(keydown.enter)="$event.preventDefault(); openClientSearch()"
placeholder="Client ID, or search by name or mobile number…"
autocomplete="off"
/>
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete
#clientAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displayClient"
<!-- Opens Search Customer; picking a customer puts them here, which fills the rest. -->
<button
matSuffix
type="button"
class="pm-search-btn"
aria-label="Search customer"
matTooltip="Search by ID, name or mobile number"
[disabled]="partyForm.get('clientId')?.disabled"
(click)="$event.stopPropagation(); openClientSearch()"
>
@for (client of clientOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
<span class="pm-opt-id">{{ client.id }}</span>
<span class="pm-opt-main">{{ client.displayName }}</span>
<span class="pm-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
<!-- Says so when the list is only the first page, so a customer missing from
it reads as "keep typing" rather than "not a customer here". -->
@if (clientListIsPartial) {
<mat-option disabled class="pm-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (loadedClientName) {
<i class="fas fa-search"></i>
</button>
@if (clientNeedsSearch) {
<mat-hint class="pm-hint--missing"
>Press search to find this client by name or mobile number</mat-hint
>
} @else if (loadedClientName) {
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
@@ -228,18 +236,28 @@
</div>
}
<!-- Not a field: the server gives the code when the party is saved. A new party is shown the
code it is expected to get; one already on the books shows the code it has. -->
<div class="pm-f">
<label class="pm-f-lbl">Party Code</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
<div class="pm-code">
<span class="pm-code__value">{{
(partyId ? partyForm.get('partyCode')?.value : expectedPartyCode) || '—'
}}</span>
<span class="pm-code__hint">{{
partyId ? 'Assigned when this party was opened' : 'Expected - assigned when saved'
}}</span>
</div>
</div>
<!-- Not a field: the number of the DDS account opened most recently, for reference. Read from
the books and never saved. -->
<div class="pm-f">
<label class="pm-f-lbl">Last A/c</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
<div class="pm-code">
<span class="pm-code__value">{{ lastAccountNo || '—' }}</span>
<span class="pm-code__hint">Latest DDS account opened</span>
</div>
</div>
<div class="pm-f">
@@ -251,7 +269,27 @@
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</div>
</mat-tab>
<!-- The same section, set out over its own tab: who the party is, where, and how to reach them. -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-address-card pm-tab-icon"></i>Personal &amp; Contact
@if (tabHasError('personal')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-address-card"></i>
<h3>Personal &amp; Contact</h3>
<span class="pm-section__sub">Name, address, contact and identity - filled from the client</span>
</div>
<div class="pm-grid">
<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">
@@ -427,7 +465,17 @@
</div>
</div>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-child pm-tab-icon"></i>Minor / Guardian
@if (tabHasError('minor')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
@@ -519,7 +567,17 @@
<p class="pm-note">An adult's account needs no guardian details.</p>
}
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-day pm-tab-icon"></i>Scheme Terms
@if (tabHasError('scheme')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
@@ -620,7 +678,17 @@
<!-- Withdraw Type is decided when the account is paid out, not when it is opened. -->
</div>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale pm-tab-icon"></i>Balances &amp; Status
@if (tabHasError('balances')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
@@ -761,8 +829,19 @@
<!-- Adv Code is decided when the account is paid out, not when it is opened. -->
</div>
</div>
</div>
</mat-tab>
@if (creditToRequired) {
<!-- Only when the account is paid out into a savings account - as the section always was. -->
@if (creditToRequired) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt pm-tab-icon"></i>Maturity Payout
@if (tabHasError('payout')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
@@ -821,15 +900,30 @@
</div>
</div>
</div>
}
</div>
</mat-tab>
</div>
</mat-tab>
}
<mat-tab label="Nominee's Detail">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-user-friends pm-tab-icon"></i>Nominee's Detail
@if (tabHasError('nominee')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-grid">
<div class="pm-f">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Nominee's Detail</h3>
<span class="pm-section__sub"
>Who the deposit is paid to if the account holder is not there to claim it</span
>
</div>
<!-- Whether there are nominees, how the deposit is split between them, and how it adds up. -->
<div class="pm-nom-bar">
<div class="pm-f pm-nom-bar__applicable">
<label class="pm-f-lbl">Nominee</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="nomineeApplicable">
@@ -840,139 +934,172 @@
</div>
@if (nomineesRequired) {
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
<div class="pm-f pm-nom-bar__mode">
<label class="pm-f-lbl">Split by</label>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-nom-mode">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
</div>
@if (splitByPercentage && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated
@if (nomineeShareError) {
— {{ nomineeShareError }}
}
</span>
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeShareError">
<div class="pm-nom-total__text">
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
@if (nomineeShareError) {
<span class="pm-nom-total__error">— {{ nomineeShareError }}</span>
}
</div>
<div class="pm-nom-total__track">
<div
class="pm-nom-total__fill"
[class.pm-nom-total__fill--done]="nomineeShareTotal === 100"
[style.width.%]="nomineeShareTotal > 100 ? 100 : nomineeShareTotal"
></div>
</div>
</div>
} @else if (splitBySequence && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</span>
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeOrderError">
<div class="pm-nom-total__text">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</div>
</div>
}
<button mat-stroked-button type="button" class="pm-nom-add" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
}
</div>
@if (!nomineesRequired) {
<p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p>
} @else if (nominees.length === 0) {
<div class="pm-nom-empty">
<i class="fas fa-user-plus"></i>
<p>No nominee added yet.</p>
<span>Use Add Nominee to name who the deposit is paid to.</span>
</div>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Nom.Dt<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
<div class="pm-nom-card" [formGroup]="$any(nominee)">
<div class="pm-nom-card__head">
<span class="pm-nom-card__no">{{ i + 1 }}</span>
<span class="pm-nom-card__title">Nominee {{ i + 1 }}</span>
@if (isNomineeMinor(nominee)) {
<span class="pm-nom-card__badge">Minor</span>
}
<button
type="button"
class="pm-nom-card__remove"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
aria-label="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="name" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Birth Dt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
<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" />
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">
Guar. Name
@if (isNomineeMinor(nominee)) {
<b>*</b>
}
</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
@if (isNomineeMinor(nominee)) {
<mat-hint>Required for a minor</mat-hint>
}
</mat-form-field>
</div>
@if (splitByPercentage) {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Share %<b>*</b></label>
<div class="pm-nom-card__grid">
<div class="pm-f">
<label class="pm-f-lbl">Nomination Date<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 required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
</div>
} @else {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Order<b>*</b></label>
<div class="pm-f">
<label class="pm-f-lbl">Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
<input matInput required formControlName="name" />
</mat-form-field>
</div>
}
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Birth Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="relation">
@for (relation of relationChoices(nominee); track relation) {
<mat-option [value]="relation">{{ relation }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
@if (splitByPercentage) {
<div class="pm-f">
<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"
/>
</mat-form-field>
</div>
} @else {
<div class="pm-f">
<label class="pm-f-lbl">Order<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
</mat-form-field>
</div>
}
<!-- Only for a minor, who cannot receive the money themselves - an adult has no guardian to
name. Whatever was typed is cleared when the date of birth makes them an adult. -->
@if (isNomineeMinor(nominee)) {
<div class="pm-f pm-nom-card__guardian">
<label class="pm-f-lbl">Guardian Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="guardianName" />
<mat-hint>Required for a minor</mat-hint>
</mat-form-field>
</div>
}
</div>
</div>
}
</div>
@@ -1036,6 +1163,16 @@
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
}
<button
mat-raised-button
color="primary"
@@ -135,75 +135,12 @@ $primary: #0f766e;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
/*
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
* sees the state of the whole nomination without having to try to save it.
*/
.pm-share-total {
align-self: center;
margin-left: 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.pm-share-total-bad {
color: #b3261e;
font-weight: 500;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
/*
* One nominee, laid out as fields that wrap rather than columns that must all fit.
*
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
*/
.pm-nominee-row {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: start;
padding: 12px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
.pm-f {
flex: 1 1 170px;
min-width: 0;
}
.pm-f--date {
flex: 0 1 140px;
}
.pm-f--tight {
flex: 0 1 100px;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
@@ -454,24 +391,6 @@ input.pm-money {
}
}
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
// no room for a label column eight fields across.
.pm-nominee-row {
gap: 0 10px;
padding: 3px 0;
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 1px;
}
.pm-f-lbl {
text-align: left;
padding-top: 0;
font-size: 10.5px;
}
}
.pm-note {
margin: 0;
font-size: 0.74rem;
@@ -526,3 +445,478 @@ input.pm-money {
border-color: rgb(180 83 9 / 25%);
color: #b45309;
}
/*
* Modern look.
*
* The Agent Master Entry screen's look, carried over to this one: an icon and a line of explanation in the header, a
* soft card with a tab bar, each group of fields in a panel of its own, labels above the fields they name. The fields,
* their order and every rule about them are as they were; only how they are drawn has changed. Kept compact - the
* fields stay short and the hint line under each stays, so an operator opening accounts all day still sees a good part
* of the form at once.
*/
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$danger: #b3261e;
.pm-container {
padding: 20px 16px 24px;
}
.pm-header {
gap: 14px;
margin-bottom: 16px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title {
font-size: 22px;
font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
}
.pm-card {
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
// Field outlines: a quiet grey that turns to the brand colour when the field is used, with softer corners.
--mat-form-field-outlined-container-shape: 8px;
--mat-form-field-outlined-outline-color: #cbd5e1;
--mat-form-field-outlined-hover-outline-color: #94a3b8;
--mat-form-field-outlined-focus-outline-color: #{$primary};
}
.pm-tabs {
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
// Seven tabs now share the row, so each gives up a little of its padding.
.mat-mdc-tab {
min-width: 100px;
padding: 0 14px;
height: 52px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
// A date picker's panel and a field's hint open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
.pm-tab-icon {
margin-right: 8px;
font-size: 14px;
}
// ── Nominee's Detail ────────────────────────────────────────────────────────────────────────────────
// What decides the nomination - whether there are nominees, how the deposit is split, how it adds up -
// sits in one bar above the nominees; each nominee is a card of its own.
.pm-nom-bar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 8px 20px;
margin-bottom: 18px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #f8fafc;
&__applicable {
flex: 0 0 150px;
}
&__mode {
flex: 0 0 auto;
}
}
// Percentage or Sequence, as two pills rather than two loose radio buttons.
.pm-nom-mode {
display: inline-flex;
gap: 8px;
height: 56px;
align-items: center;
::ng-deep .mat-mdc-radio-button {
padding: 0 12px 0 2px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-size: 13px;
}
::ng-deep .mat-mdc-radio-button.mat-mdc-radio-checked {
border-color: #1f7a4d;
background: #e6f4ee;
font-weight: 600;
}
}
.pm-nom-total {
flex: 1 1 240px;
align-self: center;
margin-bottom: 14px;
min-width: 200px;
&__text {
font-size: 13px;
color: $text-mid;
}
&__error {
margin-left: 4px;
}
&__track {
height: 6px;
margin-top: 6px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 999px;
background: #f59e0b;
transition: width 0.2s ease;
&--done {
background: #16a34a;
}
}
// The split does not add up: the same warning colour the form has always used for it.
&--bad &__text {
color: $danger;
font-weight: 500;
}
&--bad &__fill {
background: $danger;
}
}
.pm-nom-add {
margin-bottom: 14px;
color: $primary;
white-space: nowrap;
}
.pm-nom-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: #94a3b8;
text-align: center;
i {
font-size: 26px;
}
p {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $text-mid;
}
span {
font-size: 12.5px;
}
}
.pm-nom-card {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
&__no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #e6f4ee;
color: #0f4f3c;
font-size: 12px;
font-weight: 700;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__badge {
padding: 2px 10px;
border-radius: 999px;
background: #fef3c7;
color: #b45309;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
&__remove {
margin-left: auto;
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 16px;
}
// The guardian is named on a line of its own, where it reads as belonging to the minor above it.
&__guardian {
grid-column: 1 / -1;
}
}
@media (width <= 900px) {
.pm-nom-card__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 560px) {
.pm-nom-card__grid {
grid-template-columns: 1fr;
}
}
// The party code, shown rather than asked for: it is given when the party is saved.
.pm-code {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: #e6f4ee;
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0f4f3c;
}
&__hint {
font-size: 11.5px;
color: #475569;
}
}
// A tab that holds a field still to be put right.
.pm-tab-alert {
margin-left: 8px;
font-size: 14px;
color: #b3261e;
}
.pm-tab-content {
padding: 16px 20px 6px;
}
// Each group of fields in a panel of its own, its name beside a small tinted icon.
.pm-section {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
&:last-of-type {
margin-bottom: 8px;
border-bottom: 1px solid $border;
}
&__header {
margin-bottom: 10px;
gap: 2px 10px;
i {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: $primary;
font-size: 12px;
}
h3 {
font-size: 12px;
color: $primary-dark;
}
}
}
.pm-section__sub {
font-size: 0.76rem;
color: $text-mid;
}
.pm-grid {
gap: 4px 16px;
}
// The label above the field it names, rather than beside it.
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 2px;
}
// Material leaves 16px under every field for its own hint; the hint line is already reserved below, so the label above
// the next field is what separates the rows.
.pm-f .pm-field {
margin-bottom: 4px;
}
.pm-f-lbl {
padding-top: 0;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
text-align: left;
color: $text-mid;
b {
color: $danger;
}
}
.pm-card {
::ng-deep {
.mat-mdc-form-field-infix {
min-height: 30px;
}
// A field that cannot be typed in reads as filled-in fact, not as an empty box.
.mat-mdc-form-field.mat-form-field-disabled .mat-mdc-text-field-wrapper {
background: #f1f5f9;
}
.mat-mdc-form-field-hint {
color: $text-mid;
}
}
}
// The search icon is a button: it opens Search Customer.
.pm-search-btn {
margin-right: 4px;
padding: 4px 7px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
.pm-action-bar {
padding: 12px 20px;
background: #f8fafc;
}
.pm-callout {
margin: 0 20px 12px;
border-radius: 10px;
}
@@ -23,6 +23,10 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDialog } from '@angular/material/dialog';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -33,7 +37,6 @@ 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';
@@ -121,6 +124,133 @@ export class CreatePartyComponent implements OnInit {
}
selectedTabIndex = 0;
/**
* How a nominee is related to the account holder, picked rather than typed. Read from the bank's own
* RELATIONSHIP code - m_code and m_code_value, the list a client's family members are recorded
* against - and from nowhere else, so a relation added or retired there shows up here.
*/
relationOptions: string[] = [];
/**
* The relations to offer on one nominee: the list, plus whatever that nominee already holds when it
* is not on it - a relation saved before this was a list must still show when edited.
*/
relationChoices(nominee: AbstractControl): string[] {
const current = String(nominee.get('relation')?.value ?? '').trim();
return current && !this.relationOptions.includes(current) ? [
...this.relationOptions,
current
] : this.relationOptions;
}
/** The code a new party is expected to get - an estimate; the server gives the real one on saving. */
expectedPartyCode = '';
/**
* The number of the DDS account opened most recently, shown as Last A/c. For reference only - it is
* read from the books, never typed, and nothing is saved from it.
*/
lastAccountNo = '';
/**
* Customer & Scheme, Personal & Contact, Minor / Guardian, Scheme Terms, Balances & Status,
* Nominee's Detail - and Maturity Payout between the last two when the account is paid into a
* savings account. Every field is part of the one form whichever tab it is on, so moving between
* tabs loses and changes nothing.
*/
get tabCount(): number {
return this.creditToRequired ? 7 : 6;
}
/** The controls each tab holds, so a tab can say it still has something to put right. */
private static readonly TAB_CONTROLS: Record<string, string[]> = {
customer: [
'clientId',
'subType',
'agentId',
'officeId',
'partyCode',
'nominal'
],
personal: [
'partyName',
'partyName1',
'address1',
'address2',
'address3',
'city',
'pincode',
'gender',
'telephone',
'mobileNoSms',
'aadharNo',
'panCardNo',
'ckycId',
'operation',
'birthDate',
'occupation',
'risk'
],
minor: [
'adultMinor',
'guardianClientId',
'minAccountOpt',
'minorDate',
'relation'
],
scheme: [
'openingDate',
'dailyDepositAmount',
'days',
'month',
'depositAmount',
'maturityDate',
'ledgerNo',
'reopenDate'
],
balances: [
'freezeCode',
'interestDate',
'isEmailActive',
'isSmsActive',
'lien',
'lienReason',
'lienReasonOther',
'od1Flag',
'od2Flag',
'payDate',
'spDescription'
],
payout: [
'creditToAccount',
'creditToAccountType',
'creditToBranch'
],
nominee: [
'nomineeApplicable',
'nomineeSplitMode'
]
};
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the nominee tab - a nominee whose shares or order do not add up. Display only; Save is decided as
* before.
*/
tabHasError(tab: string): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (tab === 'nominee') {
const rows = this.nominees.controls.some((row) =>
Object.values((row as UntypedFormGroup).controls).some((control) => visited(control))
);
const flagged = CreatePartyComponent.TAB_CONTROLS['nominee'].some((name) => visited(this.partyForm.get(name)));
return rows || flagged || !!this.nomineeShareError || !!this.nomineeOrderError;
}
return (CreatePartyComponent.TAB_CONTROLS[tab] ?? []).some((name) => visited(this.partyForm.get(name)));
}
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
@@ -152,29 +282,9 @@ export class CreatePartyComponent implements OnInit {
agentLookupFailed = false;
loadedGuardianName = '';
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */
clientOptions: any[] = [];
/** True when something other than an id has been typed in Client ID or Name: it is found with the search button. */
clientNeedsSearch = false;
/**
* The first page of clients, shown the moment the field is focused so the officer can pick from a
* list instead of having to know something to type. Fetched once and kept.
*
* Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank
* with far more later, and the field has to stay usable in both. Typing still searches every
* client, not just these - see searchClients.
*/
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
/** How many clients exist in total, to say so when the list below is only part of them. */
clientTotal = 0;
/** True while the options being shown are the opening list rather than search results. */
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
guardianOptions: any[] = [];
/** The account types matching what has been typed into Sub Type. */
subTypeOptions: any[] = [];
@@ -326,12 +436,6 @@ export class CreatePartyComponent implements OnInit {
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
@@ -549,6 +653,30 @@ export class CreatePartyComponent implements OnInit {
}
});
this.clientsService.getCodeValuesByName('RELATIONSHIP').subscribe((values: any[]) => {
const names = (values || [])
.filter((value: any) => value?.active !== false)
.map((value: any) => titleCase(String(value?.name ?? '').replace(/_/g, ' ')))
.filter((name: string) => !!name);
if (names.length > 0) {
this.relationOptions = names;
}
});
// The code a new party is expected to get, shown in place of a Party Code field - the server gives
// the code when the party is saved, never keyed. Read for an edit too, since Cancel leaves it
// ready for a new entry. A failure just leaves the card without a figure.
this.organizationService.getNextPartyCode().subscribe({
next: (result: any) => {
this.expectedPartyCode = result?.partyCode ?? '';
this.lastAccountNo = result?.lastAccountNo ?? '';
},
error: () => {
this.expectedPartyCode = '';
this.lastAccountNo = '';
}
});
const routeId = this.route.snapshot.paramMap.get('partyId');
if (routeId) {
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
@@ -567,9 +695,9 @@ export class CreatePartyComponent implements OnInit {
// ---------------------------------------------------------------- lookups
/**
* Fills the identity block from the customer, the way the legacy screen did - except that the
* customer can be found by name as well as by id, since an officer at the counter is told a name.
* The control holds the picked client object, or whatever has been typed so far.
* Fills the identity block from the customer, the way the legacy screen did - keyed in as an id, which is fetched as
* it is typed (the way the Agent screen does it), or found by name or mobile number with the search button, which
* puts the id in the box.
*/
private wireClientLookup(): void {
this.partyForm
@@ -584,72 +712,24 @@ export class CreatePartyComponent implements OnInit {
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
this.loadedClientName = '';
this.clientLookupFailed = false;
this.clientNeedsSearch = false;
// Picked from the list.
if (value && typeof value === 'object') {
this.clientOptions = [];
this.fillFromClient(value.id, value.displayName);
return;
}
const typed = String(value ?? '').trim();
const typed = String(value && typeof value === 'object' ? value.id : (value ?? '')).trim();
if (!typed) {
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
this.showClientList();
return;
}
// A bare number is an id typed straight in; anything else is a name to search for. A number
// can be both, so both are tried and whichever matches wins.
// A bare number is an id typed straight in.
if (/^\d+$/.test(typed)) {
this.fillFromClient(typed, '');
this.fillFromClient(typed, value && typeof value === 'object' ? value.displayName : '');
return;
}
this.searchClients(typed);
// A name is not looked up as it is typed; the search button finds the customer and puts the id here.
this.clientNeedsSearch = true;
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
/**
* Opens the customer list when the field is focused.
*
* Without this the officer has to know something before anything appears, which is backwards: the
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
*/
onClientFocus(): void {
if (this.partyForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the opening list, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.initialClients;
return;
}
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.initialClients;
},
// A failed list is not a failed screen - the officer can still type a name or an id.
error: () => (this.clientOptions = [])
});
}
private fillFromClient(clientId: string | number, knownName: string): void {
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
@@ -777,6 +857,35 @@ export class CreatePartyComponent implements OnInit {
});
}
/**
* Opens Search Customer - the same dialog, and the same customer search API, the Agent screen and the cash
* screens use - to find the client by ID, name or mobile number. The id of the one picked goes into the field,
* which then fills in the rest as an id keyed in does.
*/
openClientSearch(): void {
const control = this.partyForm.get('clientId');
if (!control || control.disabled) {
return;
}
// What was typed so far is searched for; a client already picked is not a search term.
const value = control.value;
const typed = value && typeof value === 'object' ? '' : String(value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// A party is a customer first: they need not hold an account to be picked, and are listed once however many
// accounts they have.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
control.setValue(String(result.clientId));
}
});
}
/** What the Client ID box shows for a picked client, rather than "[object Object]". */
displayClient = (value: any): string => {
if (!value) {
@@ -803,7 +912,13 @@ export class CreatePartyComponent implements OnInit {
return this.filledFromClient.has(controlName);
}
/** The client's risk category, from their KYC record. */
/**
* The client's risk category, PAN and CKYC number, from their KYC record.
*
* The PAN and the CKYC number are filed on the KYC record as well as - for some clients, only on -
* it, not as ID proofs. Reading the ID proofs alone left both empty for a client whose KYC held them,
* which is the record the client screens and the customer search read first.
*/
private loadClientRisk(clientId: string): void {
this.clientsService.getClientKyc(clientId, true).subscribe({
next: (kyc: any) => {
@@ -812,6 +927,8 @@ export class CreatePartyComponent implements OnInit {
.split('.')
.pop();
this.fillFromClientRecord('risk', titleCase(code));
this.fillFromClientRecord('panCardNo', kyc?.panNumber);
this.fillFromClientRecord('ckycId', kyc?.ckycNumber);
},
// A client without a KYC record just leaves the risk to be chosen.
error: () => undefined
@@ -1554,7 +1671,6 @@ export class CreatePartyComponent implements OnInit {
officeId: raw.officeId,
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
@@ -1850,7 +1966,6 @@ export class CreatePartyComponent implements OnInit {
agentId: party.agentId,
officeId: party.officeId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
@@ -159,11 +159,22 @@
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (a.closeDate) {
<span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span>
} @else {
}
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[a.id]"
(click)="$event.stopPropagation()"
matTooltip="View agent details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
@if (!a.closeDate) {
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[a.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this agent"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
@@ -176,6 +187,7 @@
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id]"
class="pl-row"
[class.am-row--inactive]="!row.active"
></tr>
@@ -11,298 +11,470 @@
<button mat-stroked-button [routerLink]="['../']" class="am-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="am-header__title">Agent Master Entry</h2>
<div class="am-header__icon"><i class="fas fa-user-tie"></i></div>
<div class="am-header__text">
<h2 class="am-header__title">Agent Master Entry</h2>
<p class="am-header__sub">
{{ agentId ? 'Update this collection agent.' : 'Register a collection agent, their accounts and guarantors.' }}
</p>
</div>
</div>
<div class="am-card">
<form [formGroup]="agentForm">
<!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client.
-->
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-id-card"></i>
<h3>Agent Details</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<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>
}
</mat-form-field>
<form [formGroup]="agentForm">
<!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client.
The fields are the same ones the legacy screen had, set out over tabs so each page is short;
they all belong to the one form, so nothing is lost by moving between tabs.
-->
<mat-tab-group
class="am-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── 1. Agent details ─────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card am-tab-icon"></i>
Agent Details
@if (tabHasError(0)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Agent Details</h3>
<p>
Choose the branch, then find the client who will act as agent - their name, address and accounts are
filled in from them.
</p>
</div>
<div class="am-grid">
<!-- Asked first: the clients offered, and whether a client can be made an agent here at all,
depend on the branch. 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">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Code</mat-label>
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- 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">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>GL Account</mat-label>
<mat-select required formControlName="glAccountId">
@for (account of glAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<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
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ 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-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
<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 readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-user-shield"></i>
<h3>Guarantor's Detail</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
</button>
</div>
@if (guarantors.length === 0) {
<p class="am-empty-guarantors">No guarantors added yet.</p>
}
@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 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…"
(keydown.enter)="$event.preventDefault(); openClientSearch()"
placeholder="Client ID, or search by name or mobile number…"
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-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>
<!-- Opens Search Customer; picking a customer puts their id here, which fills the rest. -->
<button
matSuffix
type="button"
class="am-search-btn"
aria-label="Search customer"
[disabled]="agentId !== null"
(click)="openClientSearch()"
>
<i class="fas fa-search"></i>
</button>
@if (clientNeedsSearch) {
<mat-hint class="am-hint--error">Press search to find this client by name or mobile number</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>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" />
<!-- Not a field: the server assigns the code when the agent is saved. A new agent is shown
the code it is expected to get, worked out from the agents already on the books. -->
<div class="am-code">
<span class="am-code__label">Agent Code</span>
<span class="am-code__value">{{
(agentId ? agentForm.get('agentCode')?.value : expectedAgentCode) || '—'
}}</span>
<span class="am-code__hint">{{
agentId ? 'Assigned when this agent was opened' : 'Expected - assigned when saved'
}}</span>
</div>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" />
</mat-form-field>
<!-- Not picked: a new agent is opened on the business date, and an agent being edited shows
the date it was opened on. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
<mat-label>Agent Opening Date</mat-label>
<input
matInput
required
readonly
[matDatepicker]="openingDatePicker"
formControlName="agentOpeningDate"
/>
<mat-datepicker #openingDatePicker></mat-datepicker>
<mat-hint>{{ agentId ? 'The date this agent was opened' : 'Business date' }}</mat-hint>
</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>Address 2</mat-label>
<input matInput formControlName="address2" />
<mat-label>Agent Birth Date</mat-label>
<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>
</mat-tab>
<!-- ── 2. Address & contact ─────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-map-marker-alt am-tab-icon"></i>
Address &amp; Contact
@if (tabHasError(1)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Address &amp; Contact</h3>
<p>Filled from the client; change anything that differs for the agent.</p>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
>
<i class="fas fa-trash"></i>
</div>
</div>
</mat-tab>
<!-- ── 3. Branch & accounts ─────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-university am-tab-icon"></i>
Accounts
@if (tabHasError(2)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Accounts</h3>
<p>The ledger account the agent's cash passes through, and the savings account commission is paid into.</p>
</div>
<div class="am-grid">
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field am-field--full">
<mat-label>GL Account</mat-label>
<mat-select
required
formControlName="glAccountId"
(openedChange)="!$event && glAccountSearch.setValue('')"
>
<!-- Type a code or a name to narrow the list; cleared again when the list closes. -->
<mat-option>
<ngx-mat-select-search
placeholderLabel="Search by code or name"
noEntriesFoundLabel="No account found"
[formControl]="glAccountSearch"
></ngx-mat-select-search>
</mat-option>
@for (account of filteredGlAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<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
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ 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-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo" (selectionChange)="onSavingsAccountPicked()">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
</mat-form-field>
<!-- How the chosen account stands, read from the account itself: whether it can take the
commission, what it holds, what is on hold and what is left to use. -->
@if (savingsInfoLoading) {
<div class="am-acct am-field--full am-acct--note">Reading the account…</div>
} @else if (savingsInfoFailed) {
<div class="am-acct am-field--full am-acct--note am-acct--error">
Could not read this account's balance just now.
</div>
} @else if (savingsInfo) {
<div class="am-acct am-field--full">
<div class="am-acct__tile">
<span class="am-acct__label">Account Status</span>
<span
class="am-acct__status"
[class.am-acct__status--ok]="savingsInfo.statusClass === 'ok'"
[class.am-acct__status--wait]="savingsInfo.statusClass === 'wait'"
[class.am-acct__status--off]="savingsInfo.statusClass === 'off'"
>
<i class="fas fa-circle"></i>{{ savingsInfo.status }}
</span>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.balance | number: '1.2-2' }}</span
>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Hold</span>
<span class="am-acct__value" [class.am-acct__value--hold]="savingsInfo.hold > 0">
{{ savingsInfo.symbol }}{{ savingsInfo.hold | number: '1.2-2' }}
</span>
</div>
<div class="am-acct__tile am-acct__tile--accent">
<span class="am-acct__label">Available Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.available | number: '1.2-2' }}</span
>
</div>
</div>
}
</div>
</div>
</mat-tab>
<!-- ── 4. Guarantors ────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-user-shield am-tab-icon"></i>
Guarantors
@if (guarantors.length > 0) {
<span class="am-tab-count">{{ guarantors.length }}</span>
}
@if (tabHasError(3)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro am-tab-intro--split">
<div>
<h3>Guarantor's Detail</h3>
<p>Someone other than the agent who stands behind them. Add as many as are needed.</p>
</div>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
</button>
</div>
}
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-balance-scale"></i>
<h3>Balances</h3>
@if (guarantors.length === 0) {
<div class="am-empty">
<i class="fas fa-user-shield"></i>
<p class="am-empty-guarantors">No guarantors added yet.</p>
</div>
}
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<div class="am-guarantor-head">
<span class="am-guarantor-no">{{ i + 1 }}</span>
<span class="am-guarantor-title">Guarantor {{ i + 1 }}</span>
<button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
aria-label="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="am-guarantor-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
(keydown.enter)="$event.preventDefault(); openGuarantorSearch(guarantor)"
placeholder="Client ID, or search by name or mobile number…"
autocomplete="off"
/>
<button
matSuffix
type="button"
class="am-search-btn"
aria-label="Search customer"
(click)="openGuarantorSearch(guarantor)"
>
<i class="fas fa-search"></i>
</button>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<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"
>Press search to find this client by name or mobile number</mat-hint
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
</div>
}
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
</mat-tab>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput type="number" formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
<!-- ── 5. Balances ────────────────────────────────────────────────────────────────
Only for an agent already on the books. A new agent always starts at nothing - the form
still sends 0 - so there is nothing to ask; the tab then shows what the agent holds. -->
@if (agentId) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale am-tab-icon"></i>
Balances
@if (tabHasError(4)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Balances</h3>
<p>What the agent holds on the books at the start.</p>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
<div class="am-action-bar">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput type="number" formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
</mat-tab>
}
</mat-tab-group>
<div class="am-action-bar">
<div class="am-action-bar__left">
<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>
</div>
<div class="am-action-bar__right">
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left am-btn-ic"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right am-btn-ic am-btn-ic--after"></i>
</button>
}
<button
mat-raised-button
color="primary"
@@ -313,6 +485,6 @@
Save
</button>
</div>
</form>
</div>
</div>
</form>
</div>
@@ -7,6 +7,13 @@
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$danger: #b3261e;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.am-container {
max-width: 1040px;
@@ -14,16 +21,41 @@ $primary: #0f766e;
padding: 24px 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────
.am-header {
display: flex;
align-items: center;
gap: 16px;
gap: 14px;
margin-bottom: 20px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
}
@@ -31,40 +63,110 @@ $primary: #0f766e;
color: $primary;
}
.am-card {
// ── Tabs ────────────────────────────────────────────────────────────────────
.am-tabs {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 8px 24px 24px;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
.mat-mdc-tab {
min-width: 100px;
padding: 0 16px;
height: 54px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
.mat-mdc-tab-body-wrapper {
min-height: 220px;
}
// Floating labels and the date picker's panel open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
.am-section {
padding: 20px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
.am-tab-icon {
margin-right: 8px;
font-size: 14px;
}
&:last-of-type {
border-bottom: none;
.am-tab-alert {
margin-left: 8px;
font-size: 14px;
color: $danger;
}
.am-tab-count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
background: $primary-light;
color: $primary-dark;
font-size: 11px;
font-weight: 700;
}
.am-tab-content {
padding: 22px 24px 8px;
}
.am-tab-intro {
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px dashed $border;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: $text-dark;
}
&__header {
p {
margin: 3px 0 0;
font-size: 13px;
color: $text-mid;
}
&--split {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
}
// ── Fields ──────────────────────────────────────────────────────────────────
.am-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -77,115 +179,319 @@ $primary: #0f766e;
&--wide {
grid-column: span 2;
}
}
.am-add-guarantor {
color: $primary;
}
.am-empty-guarantors {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
.am-guarantor-row {
display: grid;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
&--full {
grid-column: 1 / -1;
}
}
.am-limit-row {
// How the account commission is paid into stands: status, balance, hold, available balance.
.am-acct {
display: grid;
grid-template-columns: repeat(3, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
margin-bottom: 8px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
overflow: hidden;
&:first-of-type {
border-top: none;
&--note {
display: block;
padding: 14px 18px;
font-size: 13px;
color: $text-mid;
background: #f8fafc;
}
&--error {
color: $danger;
}
&__tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-right: 1px solid $border;
&:last-child {
border-right: 0;
}
&--accent {
background: $primary-light;
}
}
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
&--hold {
color: #b45309;
}
}
&__status {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
padding: 3px 12px 3px 9px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
i {
font-size: 8px;
}
&--ok {
background: #dcfce7;
color: #15803d;
}
&--wait {
background: #fef3c7;
color: #b45309;
}
&--off {
background: #fee2e2;
color: #b91c1c;
}
}
}
.am-remove-guarantor {
color: #b91c1c;
margin-top: 8px;
@media (width <= 720px) {
.am-acct {
grid-template-columns: repeat(2, minmax(0, 1fr));
&__tile:nth-child(2) {
border-right: 0;
}
&__tile:nth-child(-n + 2) {
border-bottom: 1px solid $border;
}
}
}
.am-action-bar {
// The agent code, shown rather than asked for: it is assigned when the agent is saved.
.am-code {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
flex-wrap: wrap;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
margin-bottom: 20px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: $primary-light;
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-mid;
}
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: $primary-dark;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
}
}
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error {
color: #b3261e;
color: $danger;
}
// The search icon is a button: it opens Search Customer.
.am-search-btn {
margin-right: 8px;
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 1rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
// ── Guarantors ──────────────────────────────────────────────────────────────
.am-add-guarantor {
color: $primary;
}
.am-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 16px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: $text-soft;
i {
font-size: 28px;
}
}
.am-empty-guarantors {
margin: 0;
font-size: 14px;
color: $text-mid;
}
.am-guarantor-row {
margin-bottom: 16px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
}
.am-guarantor-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.am-guarantor-no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: $primary-light;
color: $primary-dark;
font-size: 12px;
font-weight: 700;
}
.am-guarantor-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
.am-guarantor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.am-remove-guarantor {
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
}
// ── Action bar ──────────────────────────────────────────────────────────────
.am-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 18px;
padding: 14px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__left,
&__right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
}
.am-btn-ic {
margin-right: 8px;
font-size: 12px;
&--after {
margin-right: 0;
margin-left: 8px;
}
}
@media (width <= 900px) {
.am-grid {
.am-grid,
.am-guarantor-grid {
grid-template-columns: repeat(2, 1fr);
}
.am-guarantor-row,
.am-limit-row {
grid-template-columns: repeat(2, 1fr);
.am-tab-content {
padding: 18px 14px 4px;
}
}
/* 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;
}
@media (width <= 560px) {
.am-grid,
.am-guarantor-grid {
grid-template-columns: 1fr;
}
.am-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.am-field--wide,
.am-field--full {
grid-column: span 1;
}
.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%);
.am-action-bar__left,
.am-action-bar__right {
width: 100%;
justify-content: flex-end;
}
}
@@ -7,9 +7,10 @@
*/
/** Angular Imports */
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
UntypedFormArray,
Validators,
@@ -26,6 +27,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
@@ -33,10 +35,19 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
/** 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 { MatDialog } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { businessToday } from 'app/core/utils/business-today';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { officesUnder } from '../offices-under';
/** The chart's account type for a liability: 1 asset, 2 liability, 3 equity, 4 income, 5 expense. */
const AGENT_GL_TYPE_LIABILITY = 2;
/** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
@@ -67,7 +78,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective,
MatAutocompleteModule
MatTabsModule,
NgxMatSelectSearchModule
]
})
export class CreateAgentComponent implements OnInit {
@@ -76,17 +88,14 @@ export class CreateAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService);
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>;
private dialog = inject(MatDialog);
/**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
@@ -118,11 +127,6 @@ export class CreateAgentComponent implements OnInit {
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. */
@@ -130,17 +134,99 @@ export class CreateAgentComponent implements OnInit {
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.
* Set when what was typed in the Client ID field is not an ID - a name, or a mobile number - so the
* field can say to search for it rather than calling it a client that does not exist.
*/
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
clientNeedsSearch = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
/**
* The tabs the form is set out over - Agent Details, Address & Contact, Branch & Accounts,
* Guarantors, Balances - and the one showing. Every field is part of the one form whichever tab
* it is on, so moving between tabs loses and changes nothing.
*/
selectedTabIndex = 0;
/** Balances is the fifth tab, and only an agent already on the books has one. */
get tabCount(): number {
return this.agentId ? 5 : 4;
}
/** The controls each tab holds, in tab order, so a tab can say it still needs attention. */
private static readonly TAB_CONTROLS: string[][] = [
[
'officeId',
'clientId',
'agentCode',
'agentNameId',
'agentOpeningDate',
'agentBirthDate'
],
[
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode',
'agentTelephone'
],
[
'glAccountId',
'agentSavingAccountType',
'agentSavingAccountNo'
],
[],
[
'agentCurrentBalance',
'agentOpeningBalance'
]
];
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the first tab - a client who cannot be made an agent. Display only; Save is decided as before.
*/
tabHasError(index: number): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (index === 3) {
return this.guarantors.controls.some((row) => {
const group = row as UntypedFormGroup;
return Object.values(group.controls).some((control) => visited(control));
});
}
const controls = CreateAgentComponent.TAB_CONTROLS[index] ?? [];
const flagged = controls.some((name) => visited(this.agentForm.get(name)));
if (index === 0) {
return (
flagged ||
this.clientNeedsSearch ||
!!this.clientAlreadyAgent ||
!!this.clientAgeProblem ||
this.clientOtherBranch
);
}
return flagged;
}
/** An Indian mobile number is ten digits; a client ID is never that long. */
private static readonly MOBILE_NUMBER_LENGTH = 10;
/**
* The code the next agent is expected to get, shown in place of an Agent Code field - the code is
* assigned by the server when the agent is saved, never keyed. It is worked out the way the server
* does it: "AG" and one more than the highest number among every agent on the books, closed ones
* included. An estimate only - another agent saved first, or one waiting for approval, can take it.
*/
expectedAgentCode = '';
private static nextAgentCode(agents: any[]): string {
let highest = 0;
for (const agent of agents) {
const match = /^AG(\d+)$/.exec(String(agent?.agentCode ?? ''));
if (match) {
highest = Math.max(highest, Number(match[1]));
}
}
return `AG${String(highest + 1).padStart(4, '0')}`;
}
/**
@@ -175,6 +261,31 @@ export class CreateAgentComponent implements OnInit {
*/
glAccounts: any[] = [];
/**
* What is typed in the GL Account list's search box. The list narrows to the accounts whose code or
* name contains it, as the account pickers on the product screens do. Only what is shown changes -
* the field still holds the account's id.
*/
glAccountSearch = new UntypedFormControl('');
get filteredGlAccounts(): any[] {
const search = String(this.glAccountSearch.value ?? '')
.trim()
.toLowerCase();
if (!search) {
return this.glAccounts;
}
return this.glAccounts.filter(
(account: any) =>
String(account.glCode ?? '')
.toLowerCase()
.includes(search) ||
String(account.name ?? '')
.toLowerCase()
.includes(search)
);
}
/**
* The savings product a DDS customer's own scheme account is opened under, read from the setting
* that names it rather than guessed from a product name a bank is free to change.
@@ -202,9 +313,10 @@ export class CreateAgentComponent implements OnInit {
clientId: [
'',
// A name typed to search with is not a client; only an id picked or keyed saves.
// Up to nine digits - a mobile number is ten, so one typed in full is not mistaken for an id.
[
Validators.required,
Validators.pattern(/^\d+$/)
Validators.pattern(/^\d{1,9}$/)
]
],
agentCode: [
@@ -246,8 +358,10 @@ export class CreateAgentComponent implements OnInit {
'',
Validators.required
],
// A new agent is opened on the bank's business date, not the computer's clock; it can still be
// changed, and an agent being edited keeps the date it was opened on.
agentOpeningDate: [
'',
businessToday(),
Validators.required
],
// Filled from the client, whose date of birth was checked when they were opened.
@@ -271,13 +385,11 @@ 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.
// A client already keyed is checked against a different branch when that is chosen.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
@@ -291,6 +403,7 @@ export class CreateAgentComponent implements OnInit {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []);
},
error: () => (this.agentCodeByClientId = {})
});
@@ -325,7 +438,14 @@ export class CreateAgentComponent implements OnInit {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
// agent pointed at one would fail on their first collection rather than here.
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
//
// Liability accounts only (type 2). The cashier credits this account with the cash an agent
// hands over and Apply DDS Receipt debits it as the money is placed on the customers, so what
// stands on it is held for others - a liability. An asset, income or expense account would
// book that money as the bank's own.
this.glAccounts = rows.filter(
(a: any) => a.usage?.id === 1 && !a.disabled && a.type?.id === AGENT_GL_TYPE_LIABILITY
);
},
error: () => (this.glAccounts = [])
});
@@ -342,22 +462,63 @@ export class CreateAgentComponent implements OnInit {
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.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);
// A name or a mobile number is something to search for, not a client yet. Nothing is looked
// up as the officer types - the search button does that - so the fields stay until one is
// picked, and no client is asked for on a guess.
this.clientNeedsSearch = this.needsSearch(typed);
if (this.clientNeedsSearch) {
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));
}
}
});
}
/**
* Whether what was typed is something to search for rather than a client ID: letters, which make it
* a name, or digits as long as a mobile number.
*/
private needsSearch(typed: string): boolean {
if (!typed) {
return false;
}
return !/^\d+$/.test(typed) || typed.length >= CreateAgentComponent.MOBILE_NUMBER_LENGTH;
}
/**
* Opens Search Customer - the same dialog, and the same customer search API, the cash screens use -
* to find the agent's client by ID, name or mobile number, and puts the one picked into the field,
* which then fills the rest as an ID keyed in would.
*/
openClientSearch(): void {
if (this.agentId) {
return;
}
this.openCustomerSearch((result) => this.agentForm.get('clientId')?.setValue(String(result.clientId)));
}
/** The same search for a guarantor row, which fills that row from the client picked. */
openGuarantorSearch(group: AbstractControl): void {
this.openCustomerSearch((result) => group.get('clientId')?.setValue(String(result.clientId)), group);
}
private openCustomerSearch(picked: (result: CustomerSearchResult) => void, row?: AbstractControl): void {
const typed = String((row ?? this.agentForm).get('clientId')?.value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// An agent or guarantor is a customer first - they need not hold an account to be picked, and
// are listed once however many accounts they have. What was already typed is searched for.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
picked(result);
}
});
}
@@ -372,7 +533,7 @@ export class CreateAgentComponent implements OnInit {
'',
[
Validators.required,
Validators.pattern(/^\d+$/),
Validators.pattern(/^\d{1,9}$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
@@ -398,26 +559,16 @@ 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.
// A keyed id fills the row; a name or mobile number is found with the row's search button.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.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);
});
if (this.needsSearch(typed)) {
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) {
@@ -429,59 +580,6 @@ export class CreateAgentComponent implements OnInit {
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 => {
@@ -551,8 +649,6 @@ export class CreateAgentComponent implements OnInit {
addGuarantor(): void {
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 {
@@ -573,60 +669,6 @@ 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
@@ -645,6 +687,8 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
@@ -722,6 +766,9 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
}
}
// An agent being edited already has an account chosen: show how it stands as soon as the
// client's accounts are known.
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
},
error: () => {
this.clientSavingsAccounts = [];
@@ -738,6 +785,63 @@ export class CreateAgentComponent implements OnInit {
*/
onSavingsAccountTypePicked(): void {
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
}
/**
* How the account commission is paid into stands: its status, balance, what is on hold, and what
* is left to use. Read from the account itself, the same figures the cashier's screen shows for it,
* so the officer sees whether it can take the credit before saving rather than when a run fails.
* Display only - nothing here is saved or checked.
*/
savingsInfo: {
status: string;
statusClass: 'ok' | 'wait' | 'off';
symbol: string;
balance: number;
hold: number;
available: number;
} | null = null;
savingsInfoLoading = false;
savingsInfoFailed = false;
onSavingsAccountPicked(): void {
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
}
private showSavingsAccountInfo(accountNo: string | null | undefined): void {
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.savingsInfoLoading = false;
const match = accountNo ? this.clientSavingsAccounts.find((account: any) => account.accountNo === accountNo) : null;
if (!match) {
return;
}
this.savingsInfoLoading = true;
this.savingsService.getSavingsAccountData(String(match.id)).subscribe({
next: (account: any) => {
// The account may have been swapped for another while this was on its way.
if (this.agentForm.get('agentSavingAccountNo')?.value !== accountNo) {
return;
}
const status = account?.status ?? {};
this.savingsInfo = {
status: status.value || '—',
statusClass: status.active ? 'ok' : status.submittedAndPendingApproval || status.approved ? 'wait' : 'off',
symbol: account?.currency?.displaySymbol ?? '₹',
balance: Number(account?.summary?.accountBalance ?? 0),
// Savings carries a lien or hold as a figure on the account itself, and its available
// balance is already worked out with every hold taken off - as on the cashier's screen.
hold: Number(account?.savingsAmountOnHold ?? 0),
available: Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0)
};
this.savingsInfoLoading = false;
},
error: () => {
this.savingsInfoLoading = false;
this.savingsInfoFailed = true;
}
});
}
private loadClientAddress(clientId: string): void {
@@ -790,9 +894,14 @@ export class CreateAgentComponent implements OnInit {
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 0
agentOpeningBalance: 0,
agentOpeningDate: businessToday()
});
this.guarantors.clear();
this.savingsInfo = null;
this.savingsInfoFailed = false;
// Back to the first tab, where a fresh entry begins.
this.selectedTabIndex = 0;
}
exit(): void {
@@ -910,9 +1019,6 @@ function asDate(value: any): Date | null {
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) {
@@ -0,0 +1,193 @@
<!--
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="vsp-root va-page">
<button type="button" class="va-back" (click)="back()">
<fa-icon icon="arrow-left"></fa-icon>
<span>All agents</span>
</button>
@if (loading) {
<div class="va-state">Loading agent…</div>
} @else if (notFound || !agent) {
<div class="va-state">This agent could not be found.</div>
} @else {
<!-- HERO -->
<div class="vsp-hero">
<div class="vsp-hero-left">
<div class="vsp-hero-icon"><fa-icon icon="user-tie"></fa-icon></div>
<div class="vsp-hero-text">
<div class="vsp-hero-eyebrow">DDS Agent</div>
<h2 class="vsp-hero-title">{{ displayName }}</h2>
<div class="vsp-hero-meta">
<span class="vsp-pill vsp-pill--code">{{ agent.agentCode }}</span>
@if (agent.homeBranch) {
<span class="vsp-pill vsp-pill--teal">{{ agent.homeBranch }}</span>
}
@if (agent.closeDate) {
<span class="vsp-pill vsp-pill--gray">
<fa-icon icon="times-circle"></fa-icon>
Closed {{ agent.closeDate | dateFormat }}
</span>
} @else {
<span class="vsp-pill" [class.vsp-pill--green]="agent.active" [class.vsp-pill--amber]="!agent.active">
<fa-icon [icon]="agent.active ? 'check-circle' : 'times-circle'"></fa-icon>
{{ agent.active ? 'Active' : 'Inactive' }}
</span>
}
</div>
</div>
</div>
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (!agent.closeDate) {
<button class="vsp-edit-btn" type="button" [routerLink]="['../', agent.id, 'edit']">
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
</button>
}
</div>
<!-- DETAILS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>Details</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Agent Code</span>
<span class="vsp-v"
><strong>{{ agent.agentCode }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Client</span>
<span class="vsp-v">{{ agent.clientName || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Name ID</span>
<span class="vsp-v">{{ agent.agentNameId || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Home Branch</span>
<span class="vsp-v">{{ agent.homeBranch || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Date</span>
<span class="vsp-v">{{ agent.agentOpeningDate ? (agent.agentOpeningDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Birth Date</span>
<span class="vsp-v">{{ agent.agentBirthDate ? (agent.agentBirthDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Created</span>
<span class="vsp-v">
{{ agent.createdDate ? (agent.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Last Modified</span>
<span class="vsp-v">
{{ agent.lastModifiedDate ? (agent.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
</div>
</section>
<!-- ADDRESS & CONTACT -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="map-marker-alt"></fa-icon></div>
<h3>Address &amp; Contact</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ address || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">City</span>
<span class="vsp-v">{{ agent.agentCity || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Pin Code</span>
<span class="vsp-v">{{ agent.agentPinCode || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ agent.agentTelephone || '—' }}</span>
</div>
</div>
</section>
<!-- ACCOUNTS & BALANCES -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="coins"></fa-icon></div>
<h3>Accounts &amp; Balances</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">GL Account</span>
<span class="vsp-v">
@if (agent.glAccountCode) {
{{ agent.glAccountCode }} · {{ agent.glAccountName }}
} @else {
—
}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C Type</span>
<span class="vsp-v">{{ agent.agentSavingAccountType || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C No</span>
<span class="vsp-v">{{ agent.agentSavingAccountNo || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Current Balance</span>
<span class="vsp-v vsp-v--accent">{{ money(agent.agentCurrentBalance) }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Balance</span>
<span class="vsp-v">{{ money(agent.agentOpeningBalance) }}</span>
</div>
</div>
</section>
<!-- GUARANTORS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="user-shield"></fa-icon></div>
<h3>Guarantors ({{ agent.guarantors?.length || 0 }})</h3>
</div>
@for (guarantor of agent.guarantors || []; track $index) {
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Guarantor {{ $index + 1 }}</span>
<span class="vsp-v"
><strong>{{ guarantor.guarantorName || '—' }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ guarantor.telephone || '—' }}</span>
</div>
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ guarantorAddress(guarantor) || '—' }}</span>
</div>
</div>
} @empty {
<div class="va-empty">No guarantors on file.</div>
}
</section>
}
</div>
@@ -0,0 +1,51 @@
/**
* 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/.
*/
// Only what the agent view adds to the fixed deposit product sheet it shares: a way back to the list and a
// placeholder while the agent loads.
.va-page {
padding-top: 8px;
}
.va-back {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 12px;
border: 1px solid var(--vsp-g200);
border-radius: 8px;
background: #fff;
color: var(--vsp-g700);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--vsp-g100);
border-color: var(--vsp-g300);
}
}
.va-state {
padding: 32px 0;
text-align: center;
color: var(--vsp-g500);
font-size: 14px;
}
.va-empty {
padding: 14px 0 4px;
color: var(--vsp-g500);
font-size: 13px;
}
@@ -0,0 +1,103 @@
/**
* 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, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
/**
* A DDS collection agent, read-only: who the agent is, where they live, the accounts they collect against, their
* balances and the guarantors standing behind them. Laid out as the DDS product view is laid out - and drawn by the same
* stylesheet - so every master reads the same way. Edit leads to the same form the list does, and is not offered on an
* agent that has been closed.
*/
@Component({
selector: 'mifosx-view-agent',
templateUrl: './view-agent.component.html',
styleUrls: [
'../../../products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss',
'./view-agent.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
FaIconComponent
]
})
export class ViewAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
agent: any = null;
loading = true;
notFound = false;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('agentId');
if (!id) {
this.router.navigate(['/organization/agent-master']);
return;
}
this.organizationService.getAgent(id).subscribe({
next: (agent: any) => {
this.agent = agent;
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
money(value: any): string {
return ViewAgentComponent.MONEY.format(Number(value || 0));
}
/** The agent's name as the list shows it: the name on file, falling back to the client's. */
get displayName(): string {
return this.agent?.agentNameId || this.agent?.clientName || this.agent?.agentCode || '';
}
/** The address on one line, skipping whatever was left blank. */
get address(): string {
return [
this.agent?.agentAddress1,
this.agent?.agentAddress2,
this.agent?.agentCity,
this.agent?.agentPinCode
]
.filter((part) => !!part)
.join(', ');
}
/** A guarantor's address on one line, skipping whatever was left blank. */
guarantorAddress(guarantor: any): string {
return [
guarantor?.address1,
guarantor?.address2,
guarantor?.city,
guarantor?.pinCode
]
.filter((part) => !!part)
.join(', ');
}
back(): void {
this.router.navigate(['/organization/agent-master']);
}
}
@@ -19,6 +19,7 @@ import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/
import { OfficesComponent } from './offices/offices.component';
import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
import { EmployeesComponent } from './employees/employees.component';
@@ -254,6 +255,11 @@ const routes: Routes = [
component: CreateAgentComponent,
data: { title: 'Create Agent', breadcrumb: 'Create' }
},
{
path: ':agentId',
component: ViewAgentComponent,
data: { title: 'View Agent', breadcrumb: 'View', routeParamBreadcrumb: false }
},
{
path: ':agentId/edit',
component: CreateAgentComponent,
@@ -272,6 +272,11 @@ export class OrganizationService {
return this.http.get('/dds/parties');
}
/** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */
getNextPartyCode(): Observable<any> {
return this.http.get('/dds/parties/nextcode');
}
/**
* The parties a given agent collects for — what the daily transaction screen works from, since
* it handles one agent's round at a time.
@@ -61,16 +61,10 @@
<mat-option [value]="option">{{ option }}</mat-option>
}
</mat-select>
<!-- No posting day to pick: interest posts at period end, as on savings and fixed deposits. -->
<mat-hint>Interest posts on the last day of each month, quarter, half-year or year</mat-hint>
</mat-form-field>
<!-- Only meaningful while auto-posting is on — the legacy screen hid it the same way. -->
@if (autoPostOn) {
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Auto Interest Post Day</mat-label>
<input matInput required type="number" min="1" max="31" formControlName="autoInterestPostDay" />
</mat-form-field>
}
<!--
Asked as Yes/No rather than as a tickbox to match Auto Interest Post directly above it:
the two are the same kind of decision, and a screen that asks one of them one way and the
@@ -78,7 +78,6 @@ export class CreateDdsAccountTypeComponent implements OnInit {
'Monthly',
Validators.required
],
autoInterestPostDay: [''],
/*
* Defaults to No, so a scheme created without a thought about it keeps behaving the way every
* scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a
@@ -132,29 +131,7 @@ export class CreateDdsAccountTypeComponent implements OnInit {
return this.accountTypeId !== null;
}
/**
* "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen
* hid it the same way. "When" stays visible either way, as it did there.
*/
get autoPostOn(): boolean {
return !!this.accountTypeForm.get('autoInterestPost')?.value;
}
ngOnInit(): void {
// The posting day is required exactly while auto-posting is on, and is cleared when it is off.
const dayControl = this.accountTypeForm.get('autoInterestPostDay');
const applyDayRule = (autoPostOn: boolean) => {
if (autoPostOn) {
dayControl?.setValidators([Validators.required]);
} else {
dayControl?.clearValidators();
dayControl?.setValue('');
}
dayControl?.updateValueAndValidity();
};
applyDayRule(this.autoPostOn);
this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value));
const routeId = this.route.snapshot.paramMap.get('accountTypeId');
if (!routeId) {
return;
@@ -167,7 +144,6 @@ export class CreateDdsAccountTypeComponent implements OnInit {
accountTypeDescription: accountType.accountTypeDescription,
autoInterestPost: !!accountType.autoInterestPost,
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
@@ -190,9 +166,6 @@ export class CreateDdsAccountTypeComponent implements OnInit {
const raw = this.accountTypeForm.getRawValue();
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,
// Sent as numbers, not the strings a text input yields - the backend reads them as decimals.
interestRate: Number(raw.interestRate),
prematureLockinMonths: Number(raw.prematureLockinMonths),
@@ -93,9 +93,7 @@
<td mat-cell *matCellDef="let t">
@if (t.autoInterestPost) {
{{ t.autoInterestPostWhen || 'Yes' }}
@if (t.autoInterestPostDay) {
<span class="pl-product-sub">day {{ t.autoInterestPostDay }}</span>
}
<span class="pl-product-sub">period end</span>
} @else {
No
}
@@ -112,11 +110,22 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let t" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[t.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[t.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this account type"
*mifosxHasPermission="'UPDATE_DDSACCOUNTTYPE'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
@@ -124,7 +133,7 @@
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="pl-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
@@ -0,0 +1,164 @@
<!--
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="vsp-root vdt-page">
<button type="button" class="vdt-back" (click)="back()">
<fa-icon icon="arrow-left"></fa-icon>
<span>All DDS products</span>
</button>
@if (loading) {
<div class="vdt-state">Loading product…</div>
} @else if (notFound || !accountType) {
<div class="vdt-state">This DDS product could not be found.</div>
} @else {
<!-- HERO -->
<div class="vsp-hero">
<div class="vsp-hero-left">
<div class="vsp-hero-icon"><fa-icon icon="calendar-day"></fa-icon></div>
<div class="vsp-hero-text">
<div class="vsp-hero-eyebrow">DDS Product</div>
<h2 class="vsp-hero-title">{{ accountType.accountTypeDescription }}</h2>
<div class="vsp-hero-meta">
<span class="vsp-pill vsp-pill--code">{{ accountType.accountTypeCode }}</span>
@if (accountType.accountTypeGroup) {
<span class="vsp-pill vsp-pill--teal">{{ accountType.accountTypeGroup }}</span>
}
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.active"
[class.vsp-pill--amber]="!accountType.active"
>
<fa-icon [icon]="accountType.active ? 'check-circle' : 'times-circle'"></fa-icon>
{{ accountType.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
<button
class="vsp-edit-btn"
type="button"
[routerLink]="['../', accountType.id, 'edit']"
*mifosxHasPermission="'UPDATE_DDSACCOUNTTYPE'"
>
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
</button>
</div>
<!-- DETAILS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>Details</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Account Type Code</span>
<span class="vsp-v"
><strong>{{ accountType.accountTypeCode }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Account Type Group</span>
<span class="vsp-v">{{ accountType.accountTypeGroup || '—' }}</span>
</div>
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Description</span>
<span class="vsp-v">{{ accountType.accountTypeDescription || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Created</span>
<span class="vsp-v">
{{ accountType.createdDate ? (accountType.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Last Modified</span>
<span class="vsp-v">
{{
accountType.lastModifiedDate ? (accountType.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—'
}}
</span>
</div>
</div>
</section>
<!-- INTEREST -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="coins"></fa-icon></div>
<h3>Interest</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Interest Rate</span>
<span class="vsp-v vsp-v--accent">{{ percent(accountType.interestRate) }}</span>
<span class="vdt-note">Worked out daily on the balance (rate ÷ 365)</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Auto Interest Post</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.autoInterestPost"
[class.vsp-pill--gray]="!accountType.autoInterestPost"
>
{{ accountType.autoInterestPost ? 'Yes' : 'No' }}
</span>
</span>
</div>
@if (accountType.autoInterestPost) {
<div class="vsp-kv">
<span class="vsp-k">Auto Interest Post When</span>
<span class="vsp-v">{{ accountType.autoInterestPostWhen || 'Monthly' }}</span>
<span class="vdt-note">Posts on the last day of each period</span>
</div>
}
</div>
</section>
<!-- MATURITY & PREMATURE CLOSURE -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="calendar-day"></fa-icon></div>
<h3>Maturity &amp; Premature Closure</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Maturity Auto Close</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="accountType.maturityAutoClose"
[class.vsp-pill--gray]="!accountType.maturityAutoClose"
>
{{ accountType.maturityAutoClose ? 'Yes' : 'No' }}
</span>
</span>
<span class="vdt-note">Closes on the maturity date, if it names a savings account to be paid into</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Premature Lock-in</span>
<span class="vsp-v">{{ lockinLabel(accountType.prematureLockinMonths) }}</span>
<span class="vdt-note">Below this, early closure is refused outright</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Premature Interest Reduction</span>
<span class="vsp-v">{{ percent(accountType.prematureInterestReduction) }}</span>
<span class="vdt-note">Taken off the customer's rate when they bear the cost of closing early</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Commission Penalty</span>
<span class="vsp-v">{{ percent(accountType.prematureAgentCommissionPenalty) }}</span>
<span class="vdt-note">Taken off the agent's commission on an early closure</span>
</div>
</div>
</section>
}
</div>
@@ -0,0 +1,53 @@
/**
* 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/.
*/
// Only what the DDS view adds to the fixed deposit product sheet it shares: a way back to the
// list, a line of explanation under a term, and a placeholder while the product loads.
.vdt-page {
padding-top: 8px;
}
.vdt-back {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 12px;
border: 1px solid var(--vsp-g200);
border-radius: 8px;
background: #fff;
color: var(--vsp-g700);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover {
background: var(--vsp-g100);
color: var(--vsp-g900);
}
}
.vdt-note {
font-size: 11.5px;
line-height: 1.4;
color: var(--vsp-g500);
}
.vdt-state {
padding: 48px 16px;
text-align: center;
color: var(--vsp-g500);
background: #fff;
border: 1px solid var(--vsp-g200);
border-radius: 12px;
}
@@ -0,0 +1,83 @@
/**
* 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, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
/**
* A DDS product, read-only: the terms an account opened under it takes, laid out as the savings,
* fixed and recurring deposit product views lay theirs out - and drawn by the same stylesheet, so a
* checker or clerk reads every product the same way. Edit leads to the same form the list does.
*/
@Component({
selector: 'mifosx-view-dds-account-type',
templateUrl: './view-dds-account-type.component.html',
styleUrls: [
'../../fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss',
'./view-dds-account-type.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
FaIconComponent
]
})
export class ViewDdsAccountTypeComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private productsService = inject(ProductsService);
accountType: any = null;
loading = true;
notFound = false;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('accountTypeId');
if (!id) {
this.router.navigate(['/products/dds-account-types']);
return;
}
this.productsService.getDdsAccountType(id).subscribe({
next: (accountType: any) => {
this.accountType = accountType;
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
/** 12 → "12 months", 1 → "1 month", 0 → "None - may be closed from day one". */
lockinLabel(months: number | null | undefined): string {
if (months === null || months === undefined) {
return '—';
}
if (months === 0) {
return 'None - may be closed from day one';
}
return `${months} month${months === 1 ? '' : 's'}`;
}
/** A rate or percentage as typed, without the trailing zeros the database pads it with. */
percent(value: number | string | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return `${Number(value)}%`;
}
back(): void {
this.router.navigate(['/products/dds-account-types']);
}
}
@@ -32,6 +32,7 @@ import { RecurringDepositProductsComponent } from './recurring-deposit-products/
import { ViewRecurringDepositProductComponent } from './recurring-deposit-products/view-recurring-deposit-product/view-recurring-deposit-product.component';
import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component';
import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component';
import { ViewDdsAccountTypeComponent } from './dds-account-types/view-dds-account-type/view-dds-account-type.component';
import { ChargesComponent } from './charges/charges.component';
import { ViewChargeComponent } from './charges/view-charge/view-charge.component';
import { CreateChargeComponent } from './charges/create-charge/create-charge.component';
@@ -756,6 +757,12 @@ const routes: Routes = [
path: ':accountTypeId/edit',
component: CreateDdsAccountTypeComponent,
data: { title: 'Edit DDS Product', breadcrumb: 'Edit', routeParamBreadcrumb: false }
},
{
// After 'create', so that word is never read as a product id.
path: ':accountTypeId',
component: ViewDdsAccountTypeComponent,
data: { title: 'View DDS Product', breadcrumb: 'View', routeParamBreadcrumb: false }
}
]
},
@@ -37,10 +37,24 @@ export interface CustomerSearchDialogData {
* need one; a screen that only needs the customer (cash exchange) passes false.
*/
requireAccount?: boolean;
/**
* Whether each customer is listed once. The search returns a row per account, which an account
* screen wants; a screen that is choosing the customer itself (an agent, a guarantor) wants each
* person once however many accounts they hold.
*/
uniqueCustomers?: boolean;
/**
* What the caller had already typed, to search for on opening. All digits is read as a client ID,
* or a mobile number once it is as long as one; anything else is a name.
*/
prefill?: string;
}
type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit';
/** An Indian mobile number is ten digits; a client ID is never that long. */
const MOBILE_NUMBER_LENGTH = 10;
@Component({
selector: 'mifosx-customer-search-dialog',
standalone: true,
@@ -77,6 +91,19 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
<mat-dialog-content class="csd-content">
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
<div class="csd-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Client ID</mat-label>
<mat-icon matPrefix>badge</mat-icon>
<input
matInput
formControlName="clientId"
placeholder="Client ID"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('clientId')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
<mat-label>Name</mat-label>
<mat-icon matPrefix>person</mat-icon>
@@ -84,6 +111,20 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Mobile Number</mat-label>
<mat-icon matPrefix>call</mat-icon>
<input
matInput
formControlName="mobileNo"
placeholder="Mobile number"
maxlength="10"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('mobileNo')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--wide">
<mat-label>Account Number</mat-label>
<mat-icon matPrefix>account_balance_wallet</mat-icon>
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
@@ -266,12 +307,13 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
width: 100%;
}
.csd-field--name {
.csd-field--name,
.csd-field--wide {
grid-column: span 2;
}
.csd-field--address {
grid-column: span 3;
grid-column: 1 / -1;
}
.csd-upper {
@@ -522,6 +564,7 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
}
.csd-field--name,
.csd-field--wide,
.csd-field--address {
grid-column: span 1;
}
@@ -550,7 +593,9 @@ export class CustomerSearchDialogComponent implements OnInit {
ngOnInit(): void {
this.form = this.fb.group({
clientId: [''],
name: [''],
mobileNo: [''],
accountNumber: [''],
aadhaarNumber: [''],
panNumber: [''],
@@ -560,6 +605,36 @@ export class CustomerSearchDialogComponent implements OnInit {
fixedDeposit: [false],
recurringDeposit: [false]
});
// What the caller had typed already is searched for straight away, so opening the dialog from a
// half-filled field does not mean typing it a second time.
const typed = (this.data?.prefill ?? '').trim();
if (typed) {
const field = this.fieldFor(typed);
this.form.get(field)?.setValue(typed);
this.onSearch();
}
}
/**
* Which field something typed belongs in. Digits alone are a client ID - they are short - until
* they are as long as a mobile number, and anything with letters in it is a name.
*/
private fieldFor(typed: string): 'clientId' | 'mobileNo' | 'name' {
if (!/^\d+$/.test(typed)) {
return 'name';
}
return typed.length >= MOBILE_NUMBER_LENGTH ? 'mobileNo' : 'clientId';
}
/** Keeps a numeric field to digits however it was filled - typed, pasted or dropped. */
digitsOnly(name: string): void {
const control = this.form.get(name);
const value = String(control?.value ?? '');
const digits = value.replace(/\D/g, '');
if (value !== digits) {
control?.setValue(digits, { emitEvent: false });
}
}
/** The account screens need an account to open; a caller that only needs the customer opts out. */
@@ -627,14 +702,25 @@ export class CustomerSearchDialogComponent implements OnInit {
if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT');
if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT');
if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) {
if (
!v.clientId &&
!v.name &&
!v.mobileNo &&
!v.accountNumber &&
!v.aadhaarNumber &&
!v.panNumber &&
!v.address &&
accountTypes.length === 0
) {
this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 });
return;
}
this.loading = true;
let params = new HttpParams();
if (v.clientId) params = params.set('clientId', v.clientId);
if (v.name) params = params.set('name', v.name);
if (v.mobileNo) params = params.set('mobileNo', v.mobileNo);
if (v.accountNumber) params = params.set('accountNumber', v.accountNumber);
if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber);
if (v.panNumber) params = params.set('panNumber', v.panNumber);
@@ -643,7 +729,7 @@ export class CustomerSearchDialogComponent implements OnInit {
this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({
next: (data) => {
this.results = data || [];
this.results = this.data?.uniqueCustomers ? this.onePerCustomer(data || []) : data || [];
this.searched = true;
this.loading = false;
},
@@ -654,9 +740,17 @@ export class CustomerSearchDialogComponent implements OnInit {
});
}
/** The search answers per account; one row per customer is the first of each person's rows. */
private onePerCustomer(rows: CustomerSearchResult[]): CustomerSearchResult[] {
const seen = new Set<number>();
return rows.filter((row) => !seen.has(row.clientId) && !!seen.add(row.clientId));
}
onClear(): void {
this.form.reset({
clientId: '',
name: '',
mobileNo: '',
accountNumber: '',
aadhaarNumber: '',
panNumber: '',
@@ -0,0 +1,125 @@
<!--
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="psp-tab-content">
@if (dataSource.data.length > 0) {
<div class="psp-action-bar">
<mat-form-field appearance="outline" class="psp-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by agent, client, branch or account"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="request">
<th mat-header-cell *matHeaderCellDef>Request</th>
<td mat-cell *matCellDef="let row">
<span class="status-badge status-badge--action" [class.status-badge--delete]="row.action === 'CLOSE'">{{
requestLabel(row)
}}</span>
</td>
</ng-container>
<ng-container matColumnDef="agent">
<th mat-header-cell *matHeaderCellDef>Agent</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.agentName || '—' }}</strong>
<!-- A new agent has no code until it is approved. -->
<div class="cell-mono">{{ row.agentCode || 'Code on approval' }}</div>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let row">{{ row.clientName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="branch">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let row">{{ row.officeName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="savingAccount">
<th mat-header-cell *matHeaderCellDef>Saving A/C</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.savingAccountNo || '—' }}</td>
</ng-container>
<ng-container matColumnDef="guarantors">
<th mat-header-cell *matHeaderCellDef>Guarantors</th>
<td mat-cell *matCellDef="let row">{{ row.guarantorCount ?? 0 }}</td>
</ng-container>
<ng-container matColumnDef="maker">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let row">{{ row.makerName || row.maker }}</td>
</ng-container>
<ng-container matColumnDef="madeOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.submittedOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="status-badge status-badge--pending">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let row">
<div class="aa-actions">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<!-- Not offered to whoever made the request - another user with checker rights has to act. -->
<button
mat-stroked-button
class="psp-btn psp-btn-approve"
[disabled]="!canAct(row)"
[title]="isMine(row) ? 'You made this request, so another user has to approve it' : ''"
(click)="approve(row)"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button
mat-stroked-button
class="psp-btn psp-btn-reject"
[disabled]="!canAct(row)"
[title]="isMine(row) ? 'You made this request, so another user has to reject it' : ''"
(click)="reject(row)"
>
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
} @else if (loading) {
<div class="empty-state">
<p class="empty-title">Loading agent requests…</p>
</div>
} @else {
<div class="empty-state">
<fa-icon icon="user-tie" class="empty-icon"></fa-icon>
<p class="empty-title">No agent requests waiting</p>
<p class="empty-text">There are no agents awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,30 @@
/**
* 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/.
*/
// What the Agent Approval tab adds to the Product Approval sheet it shares.
// View, Approve and Reject sit on one line: the column takes the width its buttons need and the
// other columns - whose names and addresses can wrap - give way.
.aa-actions {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
white-space: nowrap;
.psp-btn {
flex: 0 0 auto;
margin-right: 0;
}
}
th.mat-column-actions,
td.mat-column-actions {
width: 1%;
white-space: nowrap;
}
@@ -0,0 +1,175 @@
/**
* 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 {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
import { ddsMadeOn } from '../../product-entities';
/**
* Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened,
* changed or closed.
*
* An agent has no voucher and no amount, so the money columns of DDS Approvals say nothing about one.
* This lists them by what identifies an agent - who they are, which branch, which savings account the
* commission goes into, how many guarantors - and opens the same review the DDS queue uses, which
* shows the agent and their guarantors in full and approves or rejects through the DDS queue.
*/
@Component({
selector: 'mifosx-agent-approvals',
templateUrl: './agent-approvals.component.html',
// The same look as Product Approval, so the tabs of this screen read alike.
styleUrls: [
'../pending-savings-products/pending-savings-products.component.scss',
'./agent-approvals.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class AgentApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private readonly currentUserId = this.authenticationService.getCredentials()?.userId;
dataSource = new MatTableDataSource<any>([]);
loading = true;
displayedColumns: string[] = [
'request',
'agent',
'client',
'branch',
'savingAccount',
'guarantors',
'maker',
'madeOn',
'status',
'actions'
];
ngOnInit(): void {
this.dataSource.filterPredicate = (row: any, filter: string) =>
[
row.agentName,
row.agentCode,
row.clientName,
row.officeName,
row.savingAccountNo,
row.type,
row.maker,
row.makerName
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(filter);
this.load();
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals('PENDING')
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
const agents = (Array.isArray(rows) ? rows : []).filter((row: any) => row?.entity === 'AGENT');
this.dataSource.data = agents.map((row: any) => ({ ...row, submittedOn: ddsMadeOn(row.madeOn) }));
this.loading = false;
});
}
applyFilter(value: string = ''): void {
this.dataSource.filter = value.trim().toLowerCase();
}
/** What the request is, as the badge reads it. */
requestLabel(row: any): string {
return row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit';
}
isMine(row: any): boolean {
return !!this.currentUserId && Number(row.makerId) === Number(this.currentUserId);
}
formatDate(date: Date | null): string {
return date ? date.toLocaleString('en-IN') : '';
}
/** Opens the agent in full - the client, address, branch, accounts and guarantors the request carries. */
view(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row, currentUserId: this.currentUserId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.load();
}
});
}
/**
* Whether this user may approve or reject the request. It is the same act the review and DDS
* Approvals perform, through the DDS queue, which refuses whoever made the request - so it is not
* offered to them here either.
*/
canAct(row: any): boolean {
return !this.isMine(row);
}
/**
* Approve and Reject open the agent in full rather than acting at once: a request is never actioned
* without its client, address, branch, accounts and guarantors on screen. The review carries the
* Approve and Reject buttons, and asks before rejecting.
*/
approve(row: any): void {
this.view(row);
}
reject(row: any): void {
this.view(row);
}
}
@@ -40,7 +40,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isProductEntity, isTempTableAccountEdit } from '../../product-entities';
import { isDdsRoutedRequest, isProductEntity, isTempTableAccountEdit } from '../../product-entities';
@Component({
selector: 'mifosx-checker-inbox',
@@ -118,7 +118,11 @@ export class CheckerInboxComponent implements OnInit {
*/
private excludeRoutedElsewhere(items: any[]): any[] {
return (items || []).filter(
(item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName)
(item: any) =>
!isProductEntity(item?.entityName) &&
!isTempTableAccountEdit(item?.entityName, item?.actionName) &&
// DDS product and account requests are listed under Product Approval and Pending Account.
!isDdsRoutedRequest(item?.entityName, item?.actionName)
);
}
@@ -61,7 +61,10 @@
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let account" class="cell-mono">{{ account.accountNo }}</td>
<!-- A new DDS account has no number until it is approved. -->
<td mat-cell *matCellDef="let account" class="cell-mono">
{{ account.accountNo || (account.source === 'DDS' ? 'On approval' : '') }}
</td>
</ng-container>
<ng-container matColumnDef="clientName">
@@ -35,6 +35,9 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({
selector: 'mifosx-pending-account',
@@ -64,6 +67,8 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -110,7 +115,27 @@ export class PendingAccountComponent {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** A DDS account opening or edit, which waits in - and is actioned through - the DDS approval queue. */
private isDds(account: any): boolean {
return account?.source === 'DDS';
}
navigateToAccount(account: any): void {
if (this.isDds(account)) {
// A new DDS account has no page until it is approved, so it opens the DDS review instead.
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: account.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
return;
}
const clientId = account.clientId;
const accountId = account.id;
const depositTypeId = account.depositType?.id;
@@ -190,7 +215,9 @@ export class PendingAccountComponent {
selected.forEach((account: any) => {
const depositTypeId = account.depositType?.id;
let reject$;
if (account.accountType === 'Current') {
if (this.isDds(account)) {
reject$ = this.organizationService.actOnDdsApproval(account.id, 'reject');
} else if (account.accountType === 'Current') {
reject$ = this.tasksService.rejectCurrentAccount(account.id, payload);
} else if (depositTypeId === 200) {
reject$ = this.tasksService.rejectFDAccount(account.id, payload);
@@ -223,6 +250,10 @@ export class PendingAccountComponent {
* approving from inside an individual account (see e.g. ApproveSavingsAccountComponent).
*/
private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) {
// A DDS account opens active once its request is approved; there is no separate activation step.
if (this.isDds(account)) {
return this.organizationService.actOnDdsApproval(account.id, 'approve');
}
const approvePayload = { approvedOnDate, dateFormat, locale };
const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale };
const depositTypeId = account.depositType?.id;
@@ -35,6 +35,8 @@ import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { productTypeDisplayLabel } from '../../product-entities';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({
selector: 'mifosx-pending-savings-products',
@@ -62,6 +64,7 @@ export class PendingSavingsProductsComponent {
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
private tasksService = inject(TasksService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>;
showData = false;
@@ -173,6 +176,10 @@ export class PendingSavingsProductsComponent {
* renders the submitted payload and can action the entry itself.
*/
viewDiff(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) {
this.dialog
.open(ViewCheckerInboxComponent, {
@@ -212,7 +219,35 @@ export class PendingSavingsProductsComponent {
this.loadingDiffFor = null;
}
/** A DDS product request, which is read and actioned through the DDS approval queue. */
isDds(row: any): boolean {
return row?.source === 'DDS';
}
/**
* The DDS review shows the product's terms - against today's on an edit - and approves or rejects
* it there, refusing the maker as the DDS queue does. Approve and Reject on the row open it too, so
* a DDS product is never actioned without its terms on screen.
*/
private openDdsReview(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
}
approve(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
const isEdit = this.isPendingEdit(row);
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
@@ -234,6 +269,10 @@ export class PendingSavingsProductsComponent {
}
reject(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) {
// The native /makercheckers reject endpoint takes no payload, so there is nowhere to store a
// reason - ask for plain confirmation rather than collecting text that would be discarded.
@@ -271,6 +271,13 @@
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
}
</a>
<a class="ac-tab" routerLink="./agent-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="user-tie" class="ac-tab__ic"></fa-icon>
<span>Agent Approval</span>
@if (count('agent-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
}
</a>
<a
class="ac-tab"
routerLink="./chequebook-requests"
@@ -10,17 +10,21 @@ import { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { OrganizationService } from 'app/organization/organization.service';
import { TasksService } from '../tasks.service';
import { ddsMadeOn, isDdsAccountEntry } from '../product-entities';
@Injectable()
export class GetPendingAccounts implements Resolve<any> {
private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
resolve(): Observable<any> {
return forkJoin({
savings: this.tasksService.getPendingSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))),
current: this.tasksService.getPendingCurrentAccounts().pipe(catchError(() => of([]))),
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([])))
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))),
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
}).pipe(
map((results) => {
const officeMap: Record<number, string> = {};
@@ -39,9 +43,34 @@ export class GetPendingAccounts implements Resolve<any> {
(a: any) => ({ ...a, accountType: 'Current' })
);
/*
* DDS account openings and edits wait in the DDS approval queue rather than as submitted
* accounts, so they are laid onto the same columns here. The status filter in the component
* reads status.id 100, which these carry so they are kept like any other pending account.
*/
const ddsItems = (Array.isArray(results.dds) ? results.dds : [])
.filter((entry: any) => isDdsAccountEntry(entry))
.map((entry: any) => ({
id: entry.id,
source: 'DDS',
ddsEntry: entry,
accountNo: entry.accountNo || '',
clientName: entry.clientName || entry.details,
productName: entry.productName,
accountType: entry.action === 'UPDATE' ? 'DDS · Edit' : 'DDS',
officeName: entry.officeName || '',
fieldOfficerName: entry.agentName || '',
timeline: { submittedOnDate: ddsMadeOn(entry.madeOn) },
status: {
id: 100,
value: entry.action === 'UPDATE' ? 'Edit pending approval' : 'Submitted and pending approval'
}
}));
return { pageItems: [
...savingsItems,
...currentItems
...currentItems,
...ddsItems
] };
})
);
@@ -11,8 +11,15 @@ import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
import { OrganizationService } from 'app/organization/organization.service';
import { TasksService } from '../tasks.service';
import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } from '../product-entities';
import {
ddsMadeOn,
isDdsProductEntry,
isProductEntity,
isTempTableProductEdit,
PRODUCT_ENTITY_TYPE_LABEL
} from '../product-entities';
/**
* Builds the Approvals -> Product Approval list from the two pending-request sources the platform
@@ -32,6 +39,7 @@ import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } fr
export class GetPendingSavingsProducts implements Resolve<any> {
private productsService = inject(ProductsService);
private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
resolve(): Observable<any> {
return forkJoin({
@@ -42,7 +50,8 @@ export class GetPendingSavingsProducts implements Resolve<any> {
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))),
// includeJson so a CREATE row - which has no resourceId, and therefore no product to look up -
// can still show the submitted product name.
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([])))
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))),
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
}).pipe(
map((results) => [
...this.asEditRows(results.savings, 'SAVINGS'),
@@ -50,11 +59,31 @@ export class GetPendingSavingsProducts implements Resolve<any> {
...this.asEditRows(results.share, 'SHARE'),
...this.asEditRows(results.fd, 'FD'),
...this.asEditRows(results.rd, 'RD'),
...this.asMakerCheckerRows(results.makerChecker)
...this.asMakerCheckerRows(results.makerChecker),
...this.asDdsRows(results.dds)
])
);
}
/** DDS product requests, from the DDS queue - which is also how they are viewed and actioned. */
private asDdsRows(entries: any): any[] {
return (Array.isArray(entries) ? entries : [])
.filter((entry: any) => isDdsProductEntry(entry))
.map((entry: any) => ({
id: entry.id,
source: 'DDS',
entityName: entry.entity,
actionName: entry.action,
productType: 'DDS',
savingsProductId: entry.resourceId ?? null,
savingsProductName: entry.details || entry.type,
makerUsername: entry.maker,
createdOn: ddsMadeOn(entry.madeOn),
status: 'PENDING',
ddsEntry: entry
}));
}
/** Staged product edits, unchanged apart from the source/action tags the merged table needs. */
private asEditRows(edits: any[], productType: string): any[] {
return (edits || []).map((edit: any) => ({
+49 -1
View File
@@ -56,6 +56,53 @@ export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [
'CURRENTACCOUNT'
];
/**
* DDS keeps its own approval queue (/dds/approvals), which also describes each entry in the clerk's
* terms. Its product and account requests are listed in Product Approval and Pending Account as well,
* so a checker finds them where every other product and account waits - but they are read from, and
* actioned through, that DDS queue rather than /makercheckers.
*/
export function isDdsProductEntry(entry: any): boolean {
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'DDSACCOUNTTYPE';
}
export function isDdsAccountEntry(entry: any): boolean {
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'PARTY';
}
/**
* Whether a command is a DDS product or DDS account create/edit - one listed under Product Approval or
* Pending Account, and so left out of DDS Approvals and the Checker Inbox, which would otherwise show
* the same request a second and third time.
*/
export function isDdsRoutedRequest(
entityName: string | undefined | null,
actionName: string | undefined | null
): boolean {
const entity = (entityName ?? '').toUpperCase();
// An agent request of any kind waits under Agent Approval: an agent has no voucher and no amount,
// so the money columns of DDS Approvals say nothing about one.
if (entity === 'AGENT') {
return true;
}
return [
'DDSACCOUNTTYPE',
'PARTY'
].includes(entity) && [
'CREATE',
'UPDATE'
].includes((actionName ?? '').toUpperCase());
}
/** The DDS queue's "2026-10-27 11:04" as a Date, for the tables' date columns. */
export function ddsMadeOn(madeOn: string | undefined | null): Date | null {
if (!madeOn) {
return null;
}
const d = new Date(String(madeOn).replace(' ', 'T'));
return isNaN(d.getTime()) ? null : d;
}
/** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */
export const PRODUCT_ENTITY_TYPE_LABEL: Record<string, string> = {
LOANPRODUCT: 'LOAN',
@@ -111,7 +158,8 @@ export const PRODUCT_TYPE_DISPLAY_LABEL: Record<string, string> = {
FD: 'FIXED DEPOSIT PRODUCT',
'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT',
RD: 'RECURRING DEPOSIT PRODUCT',
'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT'
'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT',
DDS: 'DDS PRODUCT'
};
/** Label shown in the Product Approval Type column. Unmapped types render unchanged. */
+6
View File
@@ -56,6 +56,7 @@ import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurr
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component';
import { AgentApprovalsComponent } from './checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component';
/** Tasks Routes */
const routes: Routes = [
@@ -235,6 +236,11 @@ const routes: Routes = [
component: DdsApprovalsComponent,
data: { title: 'DDS Approvals' }
},
{
path: 'agent-approvals',
component: AgentApprovalsComponent,
data: { title: 'Agent Approval' }
},
{
path: 'chequebook-requests',
component: ChequebookRequestsComponent,