DDS: agent collection entry screen, per-agent GL account, maturity auto close

The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -6,350 +6,175 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="am-container">
<div class="am-header">
<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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="user-tie"></fa-icon></div>
<div class="pl-header-text">
<h2>Agent Master</h2>
<p>The DDS collection agents on file.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Total agents">
<fa-icon icon="user-tie"></fa-icon>
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'agent' : 'agents' }}</span>
</div>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Agent</span>
</button>
</div>
</div>
<div class="am-card">
<form [formGroup]="agentForm">
<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</mat-label>
<input matInput required formControlName="clientId" />
</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" placeholder="Person or institution name" />
</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" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<input matInput required formControlName="homeBranch" />
</mat-form-field>
</div>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="user-tie"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCount | number }}</div>
<div class="pl-kpi-l">Total Agents</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-percent"></i>
<h3>Collection &amp; Commission Settings</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Com. Calculation</mat-label>
<mat-select required formControlName="commissionCalculation">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS</mat-label>
<mat-select required formControlName="tdsApplicable">
<mat-option value="Yes">Yes</mat-option>
<mat-option value="No">No</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS %</mat-label>
<input matInput type="number" formControlName="tdsPercent" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Cur.TDS</mat-label>
<input matInput formControlName="currentTds" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>TDS Amt</mat-label>
<input matInput formControlName="tdsAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Limit</mat-label>
<input matInput type="number" formControlName="limit" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Machine Rent</mat-label>
<input matInput type="number" formControlName="machineRent" />
</mat-form-field>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
<div class="pl-kpi-ic"><fa-icon icon="toggle-on"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ activeCount | number }}</div>
<div class="pl-kpi-l">Active Agents</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-piggy-bank"></i>
<h3>Settlement &amp; Security Accounts</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Saving Branch</mat-label>
<input matInput required formControlName="savingBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select required formControlName="agentSavingAccountType">
@for (accountType of agentSavingAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<input matInput required formControlName="agentSavingAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf Branch</mat-label>
<input matInput formControlName="transferBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf.Amt A/C Type</mat-label>
<input matInput formControlName="transferAmountAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Trf.Amt A/C No</mat-label>
<input matInput formControlName="transferAccountNo" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security Branch</mat-label>
<input matInput formControlName="agentSecurityBranch" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security A/C Type</mat-label>
<mat-select formControlName="agentSecurityAccountType">
@for (accountType of agentSavingAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Security A/C No</mat-label>
<input matInput formControlName="agentSecurityAccountNo" />
</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>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input
matInput
required
[max]="maxDate"
[matDatepicker]="birthDatePicker"
formControlName="agentBirthDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
<mat-datepicker #birthDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
<div class="pl-kpi pl-kpi--teal" matTooltip="Money collected from parties that agents have not yet handed over to a branch">
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Cash With Agents (Unsettled)</div>
</div>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-clock"></i>
<h3>Collection Limit History</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addLimit()">
<i class="fas fa-plus"></i> Add Limit
</button>
</div>
@if (limits.length === 0) {
<p class="am-empty-guarantors">No limit periods added yet.</p>
}
@for (limitRow of limits.controls; track limitRow; let i = $index) {
<div class="am-limit-row" [formGroup]="$any(limitRow)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Limit</mat-label>
<input matInput required type="number" formControlName="limit" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeLimit(i)"
matTooltip="Remove limit period"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code or name…" />
</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</mat-label>
<input matInput required formControlName="clientId" />
</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>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" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" />
</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>
</button>
</div>
}
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-wallet"></i>
<h3>Deductions &amp; Deposits</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Comm</mat-label>
<input matInput formControlName="commission" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Other Comm</mat-label>
<input matInput type="number" formControlName="otherCommission" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Ag.Deposit</mat-label>
<input matInput type="number" formControlName="agentDeposit" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Adv.Festival</mat-label>
<input matInput type="number" formControlName="festivalAdvance" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Other Deduction</mat-label>
<input matInput type="number" formControlName="otherDeduction" />
</mat-form-field>
</div>
</div>
<div class="am-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
Save
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="user-tie"></fa-icon></div>
<h3>No agents yet</h3>
<p>Create one so daily collections can be recorded against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Agent</span>
</button>
</div>
</form>
} @else {
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="agentCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Code</th>
<td mat-cell *matCellDef="let a">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="user-tie"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ a.agentCode }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Agent Name</th>
<td mat-cell *matCellDef="let a">{{ a.agentNameId }}</td>
</ng-container>
<!-- Which branch the agent's collections belong to. Its own column rather than a subtitle:
it decides which branch's batch run picks the agent up, so it has to be scannable. -->
<ng-container matColumnDef="homeBranch">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Branch</th>
<td mat-cell *matCellDef="let a">
@if (a.homeBranch) {
<span class="pl-tag">{{ a.homeBranch }}</span>
} @else {
<span class="am-no-branch">Not set</span>
}
</td>
</ng-container>
<ng-container matColumnDef="agentCity">
<th mat-header-cell *matHeaderCellDef mat-sort-header>City</th>
<td mat-cell *matCellDef="let a">{{ a.agentCity }}</td>
</ng-container>
<ng-container matColumnDef="agentSavingAccountNo">
<th mat-header-cell *matHeaderCellDef>Saving A/C No</th>
<td mat-cell *matCellDef="let a">
<span class="pl-tag">{{ a.agentSavingAccountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="agentCurrentBalance">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
<td mat-cell *matCellDef="let a" class="num">
<strong>{{ a.agentCurrentBalance | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let a">
<span class="am-pill" [class.am-pill--off]="!a.active">
{{ a.active ? 'Active' : 'Inactive' }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Created</th>
<td mat-cell *matCellDef="let a">
{{ a.createdDate ? (a.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let a" class="num">
<!-- 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--edit"
[routerLink]="[a.id, 'edit']"
matTooltip="Edit this agent"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="pl-row"
[class.am-row--inactive]="!row.active"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No agents match your filter.</span>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
}
</div>
</div>
@@ -6,136 +6,54 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
@use 'assets/styles/product-list' as pl;
.am-container {
max-width: 1040px;
margin: 0 auto;
padding: 24px 16px;
@include pl.product-list-styles;
/* Agent-specific indigo accent */
:host {
--pl-accent: #4338ca;
--pl-accent-l: #e0e7ff;
}
.am-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
/* A retired agent stays listed but reads as switched off. */
.am-row--inactive td:not(.num) {
opacity: 0.55;
}
.am-back {
color: $primary;
/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */
.pl-action-btn--close {
color: #9b2c2c;
}
.am-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 8px 24px 24px;
/* A closed agent shows when, in place of the actions it no longer has. */
.am-closed-on {
font-size: 12px;
font-style: italic;
color: #9b2c2c;
}
.am-section {
padding: 20px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
.am-pill {
display: inline-block;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.7;
background: #dcfce7;
color: #15803d;
white-space: nowrap;
}
.am-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
.am-pill--off {
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 52%);
}
.am-field {
width: 100%;
&--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;
}
}
.am-limit-row {
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%);
&:first-of-type {
border-top: none;
}
}
.am-remove-guarantor {
color: #b91c1c;
margin-top: 8px;
}
.am-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
flex-wrap: wrap;
}
@media (width <= 900px) {
.am-grid {
grid-template-columns: repeat(2, 1fr);
}
.am-guarantor-row,
.am-limit-row {
grid-template-columns: repeat(2, 1fr);
}
/* An agent with no branch is an agent no branch's batch run will collect for - say so plainly
rather than showing an em dash that reads like "nothing to see here". */
.am-no-branch {
font-size: 12px;
font-style: italic;
color: #9b2c2c;
}
@@ -7,375 +7,122 @@
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTable,
MatTableDataSource,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Agent Master Entry component.
* Agent Master list.
*
* A DDS collection agent — either a person or an institution — registered
* with its own settlement savings account and one or more guarantors. As of
* 2026-08-24 the Agent also does the daily collection work directly (the
* SubAgent tier was removed): it carries the collection-limit history,
* TDS/commission settings and security account SubAgent used to own.
*
* Backed by its own DDS Agent API (`/dds/agents`) - independent of Staff/
* Client CRUD, per the DDS agent/commission hierarchy being genuinely new
* feature work.
* The DDS collection agents on file. Follows the same list -> create/edit shape as the product lists
* and shares their stylesheet.
*/
@Component({
selector: 'mifosx-agent-master',
templateUrl: './agent-master.component.html',
styleUrls: ['./agent-master.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class AgentMasterComponent {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
export class AgentMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
maxDate = new Date();
agentId: number | null = null;
agentSavingAccountTypes = [
'Savings Deposit Individual A/C',
'Savings Deposit Joint A/C',
'Current Account'
displayedColumns: string[] = [
'agentCode',
'clientName',
'homeBranch',
'agentCity',
'agentSavingAccountNo',
'agentCurrentBalance',
'status',
'createdDate',
'actions'
];
agentForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
agentCode: [
{
value: '',
disabled: true
}
],
agentNameId: [
'',
Validators.required
],
agentAddress1: [
'',
Validators.required
],
agentAddress2: [''],
agentCity: [
'',
Validators.required
],
agentPinCode: [
'',
Validators.required
],
agentTelephone: [''],
agentSavingAccountType: [
'',
Validators.required
],
agentSavingAccountNo: [
'',
Validators.required
],
agentOpeningDate: [
'',
Validators.required
],
agentBirthDate: [
'',
Validators.required
],
agentCurrentBalance: [
{
value: 0,
disabled: true
}
],
agentOpeningBalance: [
{
value: 0,
disabled: true
}
],
homeBranch: [
'',
Validators.required
],
tdsApplicable: [
'No',
Validators.required
],
tdsPercent: [0],
currentTds: [
{
value: 0,
disabled: true
}
],
tdsAmount: [
{
value: 0,
disabled: true
}
],
savingBranch: [
'',
Validators.required
],
transferBranch: [''],
transferAmountAccountType: [''],
transferAccountNo: [''],
commissionCalculation: [
'Yes',
Validators.required
],
limit: [0],
machineRent: [0],
agentSecurityBranch: [''],
agentSecurityAccountType: [''],
agentSecurityAccountNo: [''],
commission: [
{
value: 0,
disabled: true
}
],
otherCommission: [0],
agentDeposit: [0],
festivalAdvance: [0],
otherDeduction: [0],
limits: this.formBuilder.array([]),
guarantors: this.formBuilder.array([])
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
agents: any[] = [];
filteredCount = 0;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
get totalCount(): number {
return this.agents.length;
}
get activeCount(): number {
return this.agents.filter((agent) => agent.active).length;
}
/**
* Cash the agents are collectively holding but have not settled to a branch yet — an agent's
* balance rises on every party collection and falls when it hands the money over. Inactive agents
* count too: money still out with a retired agent is exactly what this figure is for.
*/
get totalCurrentBalance(): number {
return this.agents.reduce((sum, agent) => sum + Number(agent.agentCurrentBalance || 0), 0);
}
ngOnInit(): void {
this.loadAgents();
}
private loadAgents(): void {
this.organizationService.getAgents().subscribe((agents: any[]) => {
this.agents = agents || [];
this.dataSource = new MatTableDataSource(this.agents);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.agents.length;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
get limits(): UntypedFormArray {
return this.agentForm.get('limits') as UntypedFormArray;
}
get guarantors(): UntypedFormArray {
return this.agentForm.get('guarantors') as UntypedFormArray;
}
private buildLimitGroup(): UntypedFormGroup {
return this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
limit: [
0,
Validators.required
]
});
}
private buildGuarantorGroup(): UntypedFormGroup {
return this.formBuilder.group({
clientId: [
'',
Validators.required
],
guarantorName: [
'',
Validators.required
],
address1: [
'',
Validators.required
],
address2: [''],
city: [
'',
Validators.required
],
pinCode: [
'',
Validators.required
],
telephone: ['']
});
}
addLimit(): void {
this.limits.push(this.buildLimitGroup());
}
removeLimit(index: number): void {
this.limits.removeAt(index);
}
addGuarantor(): void {
this.guarantors.push(this.buildGuarantorGroup());
}
removeGuarantor(index: number): void {
this.guarantors.removeAt(index);
}
retrieve(): void {
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.organizationService.getAgents().subscribe((agents: any[]) => {
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
if (match) {
this.patchFormFromAgent(match);
}
});
}
cancel(): void {
this.agentId = null;
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 0,
tdsApplicable: 'No',
commissionCalculation: 'Yes',
currentTds: 0,
tdsAmount: 0,
commission: 0
});
this.limits.clear();
this.guarantors.clear();
}
exit(): void {
this.router.navigate(['/organization']);
}
save(): void {
const raw = this.agentForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload: any = {
clientId: Number(raw.clientId),
agentNameId: raw.agentNameId,
agentAddress1: raw.agentAddress1,
agentAddress2: raw.agentAddress2 || undefined,
agentCity: raw.agentCity,
agentPinCode: raw.agentPinCode,
agentTelephone: raw.agentTelephone || undefined,
agentSavingAccountType: raw.agentSavingAccountType,
agentSavingAccountNo: raw.agentSavingAccountNo,
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
agentOpeningBalance: raw.agentOpeningBalance || 0,
homeBranch: raw.homeBranch,
tdsApplicable: raw.tdsApplicable === 'Yes',
tdsPercent: raw.tdsPercent || undefined,
savingBranch: raw.savingBranch,
transferBranch: raw.transferBranch || undefined,
transferAmountAccountType: raw.transferAmountAccountType || undefined,
transferAccountNo: raw.transferAccountNo || undefined,
commissionCalculation: raw.commissionCalculation === 'Yes',
limit: raw.limit || undefined,
machineRent: raw.machineRent || undefined,
agentSecurityBranch: raw.agentSecurityBranch || undefined,
agentSecurityAccountType: raw.agentSecurityAccountType || undefined,
agentSecurityAccountNo: raw.agentSecurityAccountNo || undefined,
otherCommission: raw.otherCommission || undefined,
agentDeposit: raw.agentDeposit || undefined,
festivalAdvance: raw.festivalAdvance || undefined,
otherDeduction: raw.otherDeduction || undefined,
dateFormat,
locale,
limits: (raw.limits || []).map((limit: any) => ({
fromDate: this.dateUtils.getDate(limit.fromDate),
toDate: this.dateUtils.getDate(limit.toDate),
limit: limit.limit
})),
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
clientId: Number(guarantor.clientId),
guarantorName: guarantor.guarantorName,
address1: guarantor.address1,
address2: guarantor.address2 || undefined,
city: guarantor.city,
pinCode: guarantor.pinCode,
telephone: guarantor.telephone || undefined
}))
};
if (this.agentId) {
this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit());
} else {
this.organizationService.createAgent(payload).subscribe(() => this.exit());
}
}
private patchFormFromAgent(agent: any): void {
this.agentId = agent.id;
this.agentForm.patchValue({
clientId: agent.clientId,
agentCode: agent.agentCode,
agentNameId: agent.agentNameId,
agentAddress1: agent.agentAddress1,
agentAddress2: agent.agentAddress2,
agentCity: agent.agentCity,
agentPinCode: agent.agentPinCode,
agentTelephone: agent.agentTelephone,
agentSavingAccountType: agent.agentSavingAccountType,
agentSavingAccountNo: agent.agentSavingAccountNo,
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
agentCurrentBalance: agent.agentCurrentBalance,
agentOpeningBalance: agent.agentOpeningBalance,
homeBranch: agent.homeBranch,
tdsApplicable: agent.tdsApplicable ? 'Yes' : 'No',
tdsPercent: agent.tdsPercent,
currentTds: agent.currentTds,
tdsAmount: agent.tdsAmount,
savingBranch: agent.savingBranch,
transferBranch: agent.transferBranch,
transferAmountAccountType: agent.transferAmountAccountType,
transferAccountNo: agent.transferAccountNo,
commissionCalculation: agent.commissionCalculation ? 'Yes' : 'No',
limit: agent.limit,
machineRent: agent.machineRent,
agentSecurityBranch: agent.agentSecurityBranch,
agentSecurityAccountType: agent.agentSecurityAccountType,
agentSecurityAccountNo: agent.agentSecurityAccountNo,
commission: agent.commission,
otherCommission: agent.otherCommission,
agentDeposit: agent.agentDeposit,
festivalAdvance: agent.festivalAdvance,
otherDeduction: agent.otherDeduction
});
this.limits.clear();
(agent.limits || []).forEach((limit: any) => {
const group = this.buildLimitGroup();
group.patchValue({
fromDate: limit.fromDate ? new Date(limit.fromDate) : null,
toDate: limit.toDate ? new Date(limit.toDate) : null,
limit: limit.limit
});
this.limits.push(group);
});
this.guarantors.clear();
(agent.guarantors || []).forEach((guarantor: any) => {
const group = this.buildGuarantorGroup();
group.patchValue(guarantor);
this.guarantors.push(group);
});
money(value: any): string {
return AgentMasterComponent.MONEY.format(Number(value || 0));
}
}
@@ -0,0 +1,253 @@
<!--
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="am-container">
<div class="am-header">
<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>
<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</mat-label>
<input matInput required formControlName="clientId" />
@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>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. Filled from the client's own office, and
changeable, since an agent can be on the books at one branch and work another. 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">{{ 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>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input
matInput
required
[max]="maxBirthDate"
[matDatepicker]="birthDatePicker"
formControlName="agentBirthDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
<mat-datepicker #birthDatePicker></mat-datepicker>
</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</mat-label>
<input matInput required formControlName="clientId" />
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-error>An agent cannot be its own guarantor</mat-error>
}
</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>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>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" 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>
</button>
</div>
}
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-balance-scale"></i>
<h3>Balances</h3>
</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-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>
<div class="am-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-raised-button color="primary" type="button" [disabled]="agentForm.invalid" (click)="save()">
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,146 @@
/**
* 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;
.am-container {
max-width: 1040px;
margin: 0 auto;
padding: 24px 16px;
}
.am-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.am-back {
color: $primary;
}
.am-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 8px 24px 24px;
}
.am-section {
padding: 20px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.am-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.am-field {
width: 100%;
&--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;
}
}
.am-limit-row {
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%);
&:first-of-type {
border-top: none;
}
}
.am-remove-guarantor {
color: #b91c1c;
margin-top: 8px;
}
.am-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
flex-wrap: wrap;
}
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error {
color: #b3261e;
}
@media (width <= 900px) {
.am-grid {
grid-template-columns: repeat(2, 1fr);
}
.am-guarantor-row,
.am-limit-row {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,593 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormGroup,
UntypedFormArray,
Validators,
ValidatorFn,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
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 { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
/** 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 === '') {
return;
}
group.get(controlName)?.setValue(String(value));
}
/**
* Agent Master Entry component.
*
* A DDS collection agent — either a person or an institution — registered with its own settlement
* savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field
* for field.
*
* Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way
* the legacy screen did, defaults the home branch to that client's office, and stocks the account
* type and account number dropdowns from that client's own accounts. Filled fields stay editable.
*
* Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS
* agent/commission hierarchy being genuinely new feature work.
*/
@Component({
selector: 'mifosx-create-agent',
templateUrl: './create-agent.component.html',
styleUrls: ['./create-agent.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective
]
})
export class CreateAgentComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService);
private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
/**
* The latest date of birth the picker will offer: eighteen years ago today.
*
* It was previously today, which let a clerk save an agent born on the day they were entered —
* the picker opens on today, so a stray Enter was enough. The figure matches the rule the server
* applies, so the calendar cannot offer a date the save would then refuse.
*/
maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate());
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
loadedClientName = '';
clientLookupFailed = false;
/**
* The client's own open savings accounts, fetched once the client is resolved. The commission
* account is picked from these rather than typed, so it is always an account that exists and
* always belongs to this agent.
*/
clientSavingsAccounts: any[] = [];
/**
* The account types this client actually holds, taken from their own accounts rather than a fixed
* list. A fixed list would offer types the client has nothing under, and its wording would have to
* match the product names by hand for the filter below to work at all.
*/
savingsAccountTypes: string[] = [];
/** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */
private static readonly SAVINGS_DEPOSIT_TYPE = 100;
/** Branches, for the Home Branch field. Defaulted from the client's own office. */
offices: any[] = [];
/**
* The ledger accounts an agent's collections may pass through. Header accounts are left out: they
* are headings over other accounts and carry no entries, so offering one only leads to a refusal.
*/
glAccounts: any[] = [];
/**
* 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.
*
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
* counted as that customer's deposits by every interest run afterwards. The server refuses it
* either way - this is so the choice is never offered rather than taken and then thrown back.
*/
private ddsProductId: number | null = null;
/**
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
* did, so the officer narrows to a kind of account and then picks one of that kind.
*/
get accountsOfChosenType(): any[] {
const chosenType = this.agentForm?.get('agentSavingAccountType')?.value;
if (!chosenType) {
return [];
}
return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType);
}
agentForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
agentCode: [
{
value: '',
disabled: true
}
],
agentNameId: [
'',
Validators.required
],
agentAddress1: [
'',
Validators.required
],
agentAddress2: [''],
agentCity: [
'',
Validators.required
],
agentPinCode: [
'',
Validators.required
],
agentTelephone: [''],
// Which branch this agent collects out of. Holds the office's id, not its name - the name is
// only what the dropdown shows. The DDS batch runs filter by it.
officeId: [
'',
Validators.required
],
// Asked before the account number, which is then narrowed to accounts of this type.
agentSavingAccountType: [
'',
Validators.required
],
agentSavingAccountNo: [
'',
Validators.required
],
agentOpeningDate: [
'',
Validators.required
],
agentBirthDate: [
'',
Validators.required
],
agentCurrentBalance: [
{
value: 0,
disabled: true
}
],
agentOpeningBalance: [0],
/*
* Required. An agent sharing the scheme's one account leaves a balance that says money is
* outstanding without saying whose - which is exactly what this field was added to answer.
*/
glAccountId: [
'',
Validators.required
],
guarantors: this.formBuilder.array([])
});
ngOnInit(): void {
this.organizationService.getOffices().subscribe({
next: (offices: any) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []),
error: () => (this.offices = [])
});
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
next: (config: any) => {
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
// The client may already have been keyed while this was in flight, so the list is rebuilt
// rather than left showing an account this now knows to exclude.
const clientId = this.agentForm.get('clientId')?.value;
if (clientId) { this.loadClientSavingsAccounts(String(clientId)); }
},
error: () => (this.ddsProductId = null)
});
this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => {
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);
},
error: () => (this.glAccounts = [])
});
// An id in the route means this is an edit; load that agent into the form.
const routeId = this.route.snapshot.paramMap.get('agentId');
if (routeId) {
this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent));
}
// Fill the client-derived fields as soon as typing settles - no need to tab out of the field.
this.agentForm
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe(() => {
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();
}
});
}
get guarantors(): UntypedFormArray {
return this.agentForm.get('guarantors') as UntypedFormArray;
}
private buildGuarantorGroup(): UntypedFormGroup {
const group = this.formBuilder.group({
clientId: [
'',
[
Validators.required,
this.notTheAgentItself()
]
],
guarantorName: [
'',
Validators.required
],
address1: [
'',
Validators.required
],
address2: [''],
city: [
'',
Validators.required
],
pinCode: [
'',
Validators.required
],
telephone: ['']
});
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((clientId) => this.loadGuarantorClient(group, clientId));
return group;
}
/**
* An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes
* from someone else.
*/
private notTheAgentItself(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const agentClientId = this.agentForm?.get('clientId')?.value;
if (!control.value || !agentClientId) {
return null;
}
return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null;
};
}
/** Refills a guarantor row from its Client ID, mirroring the agent's own client lookup. */
private loadGuarantorClient(group: UntypedFormGroup, clientId: any): void {
for (const controlName of ['guarantorName', 'telephone', 'address1', 'address2', 'city', 'pinCode']) {
group.get(controlName)?.setValue('');
}
if (!clientId) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
setGroupField(group, 'guarantorName', client?.displayName);
setGroupField(group, 'telephone', client?.mobileNo);
this.clientsService.getClientAddressData(String(clientId), true).subscribe({
next: (addresses: any) => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
if (!address) {
return;
}
setGroupField(group, 'address1', address.addressLine1 || address.street);
setGroupField(group, 'address2', address.addressLine2);
setGroupField(group, 'city', address.city);
setGroupField(group, 'pinCode', address.postalCode);
},
error: () => undefined
});
},
// An id that matches no client just leaves the row for the officer to fill in.
error: () => undefined
});
}
addGuarantor(): void {
this.guarantors.push(this.buildGuarantorGroup());
}
removeGuarantor(index: number): void {
this.guarantors.removeAt(index);
}
/** The fields the legacy screen filled from the client, rather than asking the officer for. */
private static readonly CLIENT_DERIVED_FIELDS = [
'agentNameId',
'agentTelephone',
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode'
];
/**
* 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
* empties them.
*/
loadClient(): void {
if (this.agentId) {
return;
}
this.clearClientDerivedFields();
this.loadedClientName = '';
this.clientLookupFailed = false;
// A different client has different accounts, so anything picked for the previous one is stale.
this.clientSavingsAccounts = [];
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
this.loadedClientName = client?.displayName || '';
this.setField('agentNameId', client?.displayName);
this.setField('agentTelephone', client?.mobileNo);
// The branch the person is on the bank's books at. Offered as a starting point rather than
// forced, since an agent can be recorded at one office and collect out of another. Set as a
// number rather than through setField, whose String() would stop it matching an option.
if (client?.officeId) {
this.agentForm.get('officeId')?.setValue(Number(client.officeId));
}
this.loadClientAddress(String(clientId));
this.loadClientSavingsAccounts(String(clientId));
},
error: () => {
this.clientLookupFailed = true;
}
});
}
/**
* The client's own open savings accounts, to be picked from rather than typed.
*
* The account number used to be free text, and the live data showed the cost: agent AG0001 carried
* "1234567890", which matches no account at all - so every commission recorded against it had
* nowhere to go. Offering only the client's real accounts makes that impossible to key, and the
* account number then carries its correct product prefix by construction rather than by memory.
*/
private loadClientSavingsAccounts(clientId: string): void {
this.clientsService.getClientAccountData(clientId).subscribe({
next: (accounts: any) => {
this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter(
(account: any) =>
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
// single sum placed for a term while a recurring one takes only its own instalments -
// neither accepts the credit, so the posting would fail rather than the choice.
// A DDS scheme account is a savings account too and so passes the test above. It is
// dropped by the product the setting names - not by a product called "DDS", which is a
// name the bank may change - so the list offers only accounts the agent can be paid in.
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
);
this.savingsAccountTypes = [
...new Set(this.clientSavingsAccounts.map((account: any) => account.productName).filter(Boolean))
];
},
error: () => {
this.clientSavingsAccounts = [];
this.savingsAccountTypes = [];
}
});
}
/**
* Clears the account number when the type changes.
*
* Without this the previously picked account stays in the field while the list below it now shows
* a different type - the form would submit an account that contradicts its own type.
*/
onSavingsAccountTypePicked(): void {
this.agentForm.get('agentSavingAccountNo')?.setValue('');
}
private loadClientAddress(clientId: string): void {
this.clientsService.getClientAddressData(clientId, true).subscribe({
next: (addresses: any) => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
if (!address) {
return;
}
this.setField('agentAddress1', address.addressLine1 || address.street);
this.setField('agentAddress2', address.addressLine2);
this.setField('agentCity', address.city);
this.setField('agentPinCode', address.postalCode);
},
// A client with no address on file is normal — the officer just types it.
error: () => undefined
});
}
private clearClientDerivedFields(): void {
for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) {
this.agentForm.get(controlName)?.setValue('');
}
}
private setField(controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
return;
}
this.agentForm.get(controlName)?.setValue(String(value));
}
retrieve(): void {
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
this.organizationService.getAgents().subscribe((agents: any[]) => {
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
if (match) {
this.patchFormFromAgent(match);
}
});
}
cancel(): void {
this.agentId = null;
this.loadedClientName = '';
this.clientLookupFailed = false;
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 0
});
this.guarantors.clear();
}
exit(): void {
this.router.navigate(['/organization/agent-master']);
}
save(): void {
const raw = this.agentForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const payload: any = {
clientId: Number(raw.clientId),
agentNameId: raw.agentNameId,
agentAddress1: raw.agentAddress1,
agentAddress2: raw.agentAddress2 || undefined,
agentCity: raw.agentCity,
agentPinCode: raw.agentPinCode,
agentTelephone: raw.agentTelephone || undefined,
officeId: Number(raw.officeId),
glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined,
agentSavingAccountType: raw.agentSavingAccountType,
agentSavingAccountNo: raw.agentSavingAccountNo,
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
agentOpeningBalance: raw.agentOpeningBalance || 0,
dateFormat,
locale,
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
clientId: Number(guarantor.clientId),
guarantorName: guarantor.guarantorName,
address1: guarantor.address1,
address2: guarantor.address2 || undefined,
city: guarantor.city,
pinCode: guarantor.pinCode,
telephone: guarantor.telephone || undefined
}))
};
if (this.agentId) {
this.organizationService.updateAgent(this.agentId, payload).subscribe(() => this.exit());
} else {
this.organizationService.createAgent(payload).subscribe(() => this.exit());
}
}
private patchFormFromAgent(agent: any): void {
this.agentId = agent.id;
this.loadedClientName = agent.clientName || '';
this.clientLookupFailed = false;
this.agentForm.patchValue({
clientId: agent.clientId,
agentCode: agent.agentCode,
agentNameId: agent.agentNameId,
agentAddress1: agent.agentAddress1,
agentAddress2: agent.agentAddress2,
agentCity: agent.agentCity,
agentPinCode: agent.agentPinCode,
agentTelephone: agent.agentTelephone,
officeId: agent.officeId ? Number(agent.officeId) : '',
glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '',
agentSavingAccountType: agent.agentSavingAccountType,
agentSavingAccountNo: agent.agentSavingAccountNo,
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
agentCurrentBalance: agent.agentCurrentBalance,
agentOpeningBalance: agent.agentOpeningBalance
});
// The account list is needed for the dropdown to show the already-saved account as chosen
// rather than blank, so it is fetched on edit as well as on the client lookup.
if (agent.clientId) {
this.loadClientSavingsAccounts(String(agent.clientId));
}
this.guarantors.clear();
for (const guarantor of agent.guarantors || []) {
const group = this.buildGuarantorGroup();
group.patchValue({
clientId: guarantor.clientId,
guarantorName: guarantor.guarantorName,
address1: guarantor.address1,
address2: guarantor.address2,
city: guarantor.city,
pinCode: guarantor.pinCode,
telephone: guarantor.telephone
});
this.guarantors.push(group);
}
}
}
@@ -27,17 +27,67 @@
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove slab">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row">
@@ -66,11 +116,15 @@
</mat-form-field>
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button
mat-icon-button
color="primary"
type="button"
[disabled]="newSlabForm.invalid"
(click)="addSlab()"
matTooltip="Add slab"
>
@@ -85,6 +139,24 @@
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-coverage__type">{{ entry.slabType }}</span>
<span class="csm-coverage__range">
covers {{ entry.from | number }} to {{ entry.to | number }}
</span>
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
@@ -9,7 +9,8 @@
$primary: #0f766e;
.csm-container {
max-width: 760px;
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
}
@@ -41,6 +42,14 @@ $primary: #0f766e;
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
th:nth-child(1), td:nth-child(1) { width: 26%; }
th:nth-child(2), td:nth-child(2) { width: 22%; }
th:nth-child(3), td:nth-child(3) { width: 22%; }
th:nth-child(4), td:nth-child(4) { width: 18%; }
th:nth-child(5), td:nth-child(5) { width: 12%; }
th {
text-align: left;
@@ -61,6 +70,8 @@ $primary: #0f766e;
.csm-inline-field {
width: 100%;
// Without this the field holds its own 180px floor and pushes the next column out of the card.
min-width: 0;
::ng-deep .mat-mdc-text-field-wrapper {
height: 44px;
@@ -90,3 +101,44 @@ $primary: #0f766e;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
/*
* What the slabs actually cover, and any figure between them that none of them claims.
*
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
*/
.csm-coverage {
margin-top: 14px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(0 0 0 / 2.5%);
display: flex;
flex-direction: column;
gap: 6px;
&__row {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 0.86rem;
}
&__type {
font-weight: 600;
min-width: 90px;
}
&__range {
color: rgb(0 0 0 / 62%);
font-variant-numeric: tabular-nums;
}
&__gap {
color: #b45309;
font-variant-numeric: tabular-nums;
}
}
@@ -14,6 +14,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Commission Slab Master component.
@@ -39,6 +40,7 @@ export class CommissionSlabMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private static readonly SLAB_TYPE_CODES: Record<string, string> = {
Account: 'ACCOUNT',
@@ -99,11 +101,85 @@ export class CommissionSlabMasterComponent implements OnInit {
});
}
/**
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
*
* A gap is not an error - a bank may well decide there is no commission below some floor - but it
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and
* moves on, so the agent is paid nothing for that month with nothing on screen to say why.
*/
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
return this.slabTypes
.map((slabType) => {
const rows = this.slabs
.filter((slab) => slab.slabType === slabType)
.map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) }))
.sort((a, b) => a.from - b.from);
if (rows.length === 0) {
return null;
}
const gaps: string[] = [];
for (let i = 1; i < rows.length; i++) {
const previousEnd = rows[i - 1].to;
const nextStart = rows[i].from;
if (nextStart > previousEnd + 1) {
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`);
}
}
return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps };
})
.filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null);
}
/**
* The slab already on the table that this range would collide with, if any.
*
* The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may
* have keyed several rows and has to work out which one it objected to. Catching it as the row is
* added names the clash while it is still the row in front of them.
*/
private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any {
return this.slabs.find(
(slab) =>
slab.slabType === candidate.slabType
&& Number(candidate.slabFrom) <= Number(slab.slabTo)
&& Number(candidate.slabTo) >= Number(slab.slabFrom)
);
}
addSlab(): void {
if (this.newSlabForm.invalid) {
// Marking the controls touched is what makes Material show which one is missing; without it
// the button simply does nothing and the officer has no idea what the screen is waiting for.
this.newSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' });
return;
}
this.slabs.push(this.newSlabForm.getRawValue());
const candidate = this.newSlabForm.getRawValue();
if (Number(candidate.slabFrom) > Number(candidate.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(candidate.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
const clash = this.clashingSlab(candidate);
if (clash) {
this.alertService.alert({
type: 'Error',
message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}`
});
return;
}
this.slabs.push(candidate);
this.newSlabForm.reset({
slabType: '',
slabFrom: 0,
@@ -112,10 +188,92 @@ export class CommissionSlabMasterComponent implements OnInit {
});
}
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */
removeSlab(index: number): void {
this.slabs.splice(index, 1);
}
/** Which saved row is currently open for correction, or -1 when none is. */
editingIndex = -1;
editSlabForm: UntypedFormGroup = this.formBuilder.group({
slabType: [
'',
Validators.required
],
slabFrom: [
0,
Validators.required
],
slabTo: [
0,
Validators.required
],
commissionPercent: [
0,
Validators.required
]
});
startEdit(index: number): void {
const slab = this.slabs[index];
this.editingIndex = index;
this.editSlabForm.setValue({
slabType: slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
});
}
cancelEdit(): void {
this.editingIndex = -1;
}
saveEdit(): void {
const slab = this.slabs[this.editingIndex];
const corrected = this.editSlabForm.getRawValue();
if (this.editSlabForm.invalid) {
this.editSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' });
return;
}
if (Number(corrected.slabFrom) > Number(corrected.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(corrected.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
this.organizationService
.updateCommissionSlab(slab.id, {
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType,
slabFrom: corrected.slabFrom,
slabTo: corrected.slabTo,
commissionPercent: corrected.commissionPercent,
locale: 'en'
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
this.editingIndex = -1;
this.loadSlabs();
},
// The backend refuses a correction that overlaps another slab; the reason has to reach the
// officer, not vanish.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab'
});
}
});
}
exit(): void {
this.router.navigate(['/organization']);
}
@@ -123,6 +281,12 @@ export class CommissionSlabMasterComponent implements OnInit {
save(): void {
const newSlabs = this.slabs.filter((slab) => !slab.id);
if (newSlabs.length === 0) {
// Save used to return here without a word, so a slab typed into the entry row but never added
// looked exactly like a slab that had been saved.
this.alertService.alert({
type: 'Error',
message: 'Nothing new to save — add the slab to the table with + first'
});
return;
}
@@ -133,6 +297,23 @@ export class CommissionSlabMasterComponent implements OnInit {
commissionPercent: slab.commissionPercent
}));
this.organizationService.createCommissionSlabs(payload).subscribe(() => this.loadSlabs());
this.organizationService.createCommissionSlabs(payload).subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
this.loadSlabs();
},
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
// happen and assume the save went through.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs'
});
}
});
}
}
@@ -11,7 +11,7 @@
<button mat-stroked-button [routerLink]="['../']" class="bp-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="bp-header__title">DDS Interest &amp; Commission Batch Processing</h2>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
</div>
<div class="bp-card">
@@ -20,8 +20,8 @@
<mat-form-field appearance="outline" class="bp-field bp-field--wide">
<mat-label>Job Type</mat-label>
<mat-select required formControlName="jobType">
@for (type of jobTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
@for (type of jobTypes; track type.code) {
<mat-option [value]="type.code">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@@ -49,7 +49,7 @@
}
<mat-form-field appearance="outline" class="bp-field">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'As-On Date' }}</mat-label>
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
@@ -65,69 +65,234 @@
<input matInput required type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. -->
@if (!isCommissionJob) {
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
<div class="bp-callout">
<i class="fas fa-info-circle"></i>
<span>
<strong>Generate</strong> runs a preview only — nothing is applied to any account. <strong>Post</strong> is a
separate, explicit step that commits the previewed amounts.
@if (isPostingJob) {
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
press <strong>Post</strong>.
} @else {
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
each account. It does not become spendable until the matching Posting job runs.
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
}
</span>
</div>
@if (hasGenerated) {
@if (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<h3>Preview</h3>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<span class="bp-section__note">
Run #{{ generatedRun.id }} · nothing applied yet
</span>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty">
No calculation preview available yet — this appears once the backend batch-job endpoint for "{{
batchForm.get('jobType').value
}}" is connected.
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th>Party</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row) {
<tr>
<td>{{ row.agent }}</td>
<td>{{ row.party }}</td>
<td>{{ row.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="bp-table-scroll">
@if (showsCommissionBreakdown) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
<th class="bp-num">Payable</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">{{ row.ratePercent }}%</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total payable</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
@if (!isCommissionJob) {
<th>Agent</th>
}
<th>Period</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
@if (isCommissionJob) {
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
} @else {
<span class="bp-code">{{ row.partyCode }}</span>
<span class="bp-sub">{{ row.partyName }}</span>
}
</td>
@if (!isCommissionJob) {
<td>{{ row.agentCode }}</td>
}
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<div class="bp-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
@if (generatedRun) {
<button mat-stroked-button type="button" (click)="discard()">Discard</button>
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
<i class="fas fa-play" style="margin-right: 6px"></i> Generate
<i class="fas fa-calculator"></i> Generate
</button>
<button mat-raised-button color="primary" type="button" [disabled]="!hasGenerated" (click)="post()">
<i class="fas fa-check" style="margin-right: 6px"></i> Post
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
</div>
</form>
</div>
<!--
A crediting run costs what a savings deposit costs on every account it touches, so the server does
it in the background and answers at once. Without this the screen would report a run as posted
seconds after it began, and the clerk would go looking for credits still being written.
-->
@if (postingRunId) {
<div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i>
<span>
<strong>Posting is running.</strong>
It continues even if you leave this screen — the list below updates when it finishes.
</span>
</div>
}
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
no way to tell whether a month's interest was ever actually posted. -->
<div class="bp-card">
<div class="bp-section__header">
<i class="fas fa-history"></i>
<h3>Previous runs</h3>
</div>
@if (recentRuns.length === 0) {
<p class="bp-empty">No batch has been run yet.</p>
} @else {
<div class="bp-table-scroll">
<table class="bp-table bp-table--runs">
<thead>
<tr>
<th>#</th>
<th>Job</th>
<th>Date</th>
<th class="bp-num">Entries</th>
<th class="bp-num">Amount</th>
<th>Status</th>
<th>Posted On</th>
</tr>
</thead>
<tbody>
@for (run of recentRuns; track run.id) {
<tr>
<td>{{ run.id }}</td>
<td>{{ labelOf(run.jobType) }}</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
<!--
Four states, not two. A run that is still crediting is not "not posted" — that
reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded.
-->
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted">Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
<i class="fas fa-circle-notch fa-spin"></i> Running
</span>
}
@case ('FAILED') {
<span class="bp-status bp-status--failed" [matTooltip]="run.failureReason || 'No reason recorded'">
Failed
</span>
}
@default {
<span class="bp-status">Not posted</span>
}
}
</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@@ -9,16 +9,20 @@
$primary: #0f766e;
.bp-container {
max-width: 900px;
// The commission breakdown carries eight columns; anything narrower makes it scroll on every
// screen rather than only on small ones.
max-width: 1180px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
.bp-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
@@ -95,8 +99,15 @@ $primary: #0f766e;
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 0 0 auto;
}
}
&__note {
margin-left: auto;
font-size: 12px;
color: rgb(0 0 0 / 45%);
}
}
.bp-empty {
@@ -105,23 +116,118 @@ $primary: #0f766e;
margin: 0;
}
// A wide table scrolls inside its own box rather than pushing the whole page sideways.
.bp-table-scroll {
overflow-x: auto;
}
.bp-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
font-size: 13px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
white-space: nowrap;
}
td {
padding: 8px 10px;
padding: 7px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: top;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
// Figures line up column-wise so a wrong order of magnitude is visible at a glance.
.bp-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bp-minus {
color: #b91c1c;
}
.bp-total {
font-weight: 600;
}
.bp-period {
white-space: nowrap;
color: rgb(0 0 0 / 60%);
}
}
.bp-code {
display: block;
font-weight: 600;
}
.bp-sub {
display: block;
font-size: 12px;
color: rgb(0 0 0 / 55%);
}
.bp-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: rgb(180 83 9 / 12%);
color: #92400e;
&--posted {
background: rgb(15 118 110 / 12%);
color: $primary;
}
/* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand,
the other is work forgotten, and a clerk who reads them the same way runs it twice. */
&--running {
background: rgb(22 84 143 / 12%);
color: #16548f;
display: inline-flex;
align-items: center;
gap: 5px;
}
&--failed {
background: rgb(163 42 28 / 12%);
color: #a32a1c;
cursor: help;
}
}
/* Says a run is still going after the clerk has left the button behind. */
.bp-running {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid rgb(22 84 143 / 25%);
border-left: 3px solid #16548f;
border-radius: 6px;
background: rgb(22 84 143 / 6%);
color: #16548f;
font-size: 13px;
strong {
font-weight: 600;
}
}
@@ -132,6 +238,10 @@ $primary: #0f766e;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
i {
margin-right: 6px;
}
}
@media (width <= 900px) {
@@ -10,60 +10,70 @@
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* DDS Interest & Commission Batch Processing component.
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
*
* Six batch job types, each following the same deliberate two-step
* pattern from the legacy system: Calculate first (a dry-run preview),
* then Post as a separate, explicit confirmation — so a wrong calculation
* is never applied by accident. Modelled on the legacy Interest
* Calculation screen this replaces.
* Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has
* been earned since the last one and parks it: interest against the customer's account, commission
* against the agent. Neither is spendable yet. The matching *posting* job then moves that parked
* figure where it belongs — into the customer's balance, or out to the agent.
*
* Backed by the DDS Batch Run API (`/dds/batchruns`). Only the three
* "Calculation" job types can actually be generated - the three "Posting"
* entries exist in the dropdown for parity with the legacy menu, but Post
* always applies whatever run was most recently generated in this session,
* regardless of which dropdown entry is currently selected (matching how
* the Post button here is only ever gated on hasGenerated, not on jobType).
* Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes
* nothing else, so a figure that looks wrong is discarded by simply not posting it.
*/
@Component({
selector: 'mifosx-dds-batch-processing',
templateUrl: './dds-batch-processing.component.html',
styleUrls: ['./dds-batch-processing.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
imports: [
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip
],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes.
providers: [
{ provide: DateAdapter, useClass: DdsDateAdapter },
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
export class DdsBatchProcessingComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private static readonly JOB_TYPE_CODES: Record<string, string> = {
'Interest Calculation': 'INTEREST_CALCULATION',
'OD Interest Calculation': 'OD_INTEREST_CALCULATION',
'Commission Calculation': 'COMMISSION_CALCULATION'
};
private currentBatchRunId: number | null = null;
offices: any[] = [];
selectedOfficeNames = new Set<string>();
jobTypes = [
'Interest Calculation',
'Interest Posting',
'OD Interest Calculation',
'OD Interest Posting',
'Commission Calculation',
'Commission Posting'
/**
* The menu as the legacy system lists it, paired with what the API calls each job. Ordered as
* calculate-then-post within each pair, matching the numbering on the legacy screen.
*/
jobTypes: Array<{ label: string; code: string }> = [
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
{ label: 'Interest Posting', code: 'INTEREST_POSTING' },
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' },
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' },
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' },
// Last, and on its own: the others move money onto accounts that stay open, while this one pays
// an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the
// second half of one.
{ label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' }
];
branchScopes = [
@@ -71,12 +81,23 @@ export class DdsBatchProcessingComponent implements OnInit {
'Selected'
];
hasGenerated = false;
previewRows: Array<{ agent: string; party: string; amount: number }> = [];
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
offices: any[] = [];
selectedOfficeNames = new Set<string>();
/** The run currently previewed on screen, or null once it has been posted or discarded. */
generatedRun: any = null;
previewRows: any[] = [];
recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({
jobType: [
'Interest Calculation',
'INTEREST_CALCULATION',
Validators.required
],
branchScope: [
@@ -105,18 +126,56 @@ export class DdsBatchProcessingComponent implements OnInit {
]
});
get jobType(): string {
return this.batchForm.get('jobType').value;
}
get jobTypeLabel(): string {
return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType;
}
/** Commission is per agent, so the party range does not narrow it. */
get isCommissionJob(): boolean {
return this.jobType.startsWith('COMMISSION');
}
get isPostingJob(): boolean {
return (this.batchForm.get('jobType').value as string).includes('Posting');
return this.jobType.endsWith('_POSTING');
}
get isBranchSelected(): boolean {
return this.batchForm.get('branchScope')?.value === 'Selected';
}
/**
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
* applied is not the figure worked out, because TDS and the standing deductions come off it.
*/
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** What Generate found, in words — the same figure the Post button is about to apply. */
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || [];
});
this.loadRecentRuns();
}
money(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
}
labelOf(code: string): string {
return this.jobTypes.find((type) => type.code === code)?.label ?? code;
}
toggleOffice(officeName: string): void {
@@ -127,59 +186,202 @@ export class DdsBatchProcessingComponent implements OnInit {
}
}
/**
* What every past run did and whether it was applied. Without this a generated-but-unposted run is
* invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted.
*/
private loadRecentRuns(): void {
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => {
this.recentRuns = runs || [];
});
}
generate(): void {
const raw = this.batchForm.getRawValue();
const jobType = DdsBatchProcessingComponent.JOB_TYPE_CODES[raw.jobType];
if (!jobType) {
// A "Posting" entry was selected - there is nothing to calculate for
// it, only to post (see class-level note on how Post is wired).
this.hasGenerated = true;
this.previewRows = [];
return;
}
const dateFormat = this.settingsService.dateFormat;
const payload: any = {
jobType,
jobType: raw.jobType,
branchScope: raw.branchScope,
jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
partyFrom: raw.partyFrom,
partyTo: raw.partyTo,
dateFormat,
locale: this.settingsService.language.code
};
// The party range narrows an interest job to particular accounts; commission is worked out per
// agent on their whole collection, so sending a range there would only imply a filter that has
// no effect.
if (!this.isCommissionJob) {
payload.partyFrom = raw.partyFrom;
payload.partyTo = raw.partyTo;
}
if (raw.branchScope === 'Selected') {
payload.officeNames = Array.from(this.selectedOfficeNames);
}
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => {
this.currentBatchRunId = result.resourceId;
this.organizationService.getDdsBatchRun(this.currentBatchRunId).subscribe((batchRun: any) => {
this.previewRows = (batchRun.rows || []).map((row: any) => ({
agent: row.agentCode,
party: row.partyCode || '—',
amount: row.amount
}));
this.hasGenerated = true;
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => {
this.generatedRun = batchRun;
this.previewRows = batchRun.rows || [];
// A run that found nothing is the normal outcome of a period already processed, not a
// failure — but it has to say so, or an empty table reads as the screen being broken.
if (this.previewRows.length === 0) {
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
this.loadRecentRuns();
});
});
}
post(): void {
if (!this.currentBatchRunId) {
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
this.organizationService.postDdsBatchRun(this.currentBatchRunId).subscribe(() => {
this.currentBatchRunId = null;
this.hasGenerated = false;
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
const isPosting = this.isPostingJob;
// Post is the step that moves money, and it cannot be undone from this screen — so it names
// exactly what is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. `
+ (isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ')
+ 'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what actually happened rather than assuming it finished.
*
* A crediting run costs what a savings deposit costs on every account it touches, so the server
* takes it off the request and does it in the background - the reply comes back in a moment while
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
* seconds after it began, and they would go looking for credits that were still being written.
*
* So the run is followed until it settles. A calculation run is done by the time the reply lands
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
* until the server marks it posted - or failed, with the reason the server recorded.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe(() => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (!crediting) {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
return;
}
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. `
+ 'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
}
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
postingRunId: number | null = null;
/**
* Watches a background run until the server marks it settled.
*
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
* the run is still on the list either way, and a clerk can read its status there.
*/
private followRun(runId: number, label: string): void {
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
const check = () => {
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
const run = (runs || []).find((each: any) => each.id === runId);
const status = run?.status;
if (status === 'POSTED' || status === 'FAILED') {
this.postingRunId = null;
this.loadRecentRuns();
this.alertService.alert(
status === 'POSTED'
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
);
return;
}
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
// Still going after the wait. Left on the list rather than reported either way - saying
// "failed" for a run that is merely long would be worse than saying nothing.
this.postingRunId = null;
this.loadRecentRuns();
}
},
// A failed status check says nothing about the run itself, which carries on regardless.
error: () => {
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
this.postingRunId = null;
}
}
});
};
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300;
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
discard(): void {
this.generatedRun = null;
this.previewRows = [];
}
exit(): void {
this.router.navigate(['/organization']);
}
@@ -0,0 +1,71 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable } from '@angular/core';
import { NativeDateAdapter, MatDateFormats } from '@angular/material/core';
/**
* Day-first dates for the DDS batch screen.
*
* Angular Material's stock adapter shows and reads dates the American way — 12/31/2026 — and its
* parser hands typed input to `Date.parse`, which cannot read a day-first date at all: typing
* 31/12/2026 yields no date, so the field silently empties and reports itself invalid.
*
* On a screen where the date decides which period's interest is calculated, both halves matter: a
* clerk who reads 12/31 as the 12th of some month picks the wrong period, and one who types the date
* they are used to gets an error with nothing to explain it.
*
* Scoped to this component rather than provided application-wide. Every other datepicker in the app
* has the same behaviour, and changing it globally would alter screens outside DDS.
*/
@Injectable()
export class DdsDateAdapter extends NativeDateAdapter {
/** Matches d/m/yyyy, with or without leading zeros, on / . or - separators. */
private static readonly DAY_FIRST = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/;
override parse(value: any): Date | null {
if (typeof value === 'string') {
const match = DdsDateAdapter.DAY_FIRST.exec(value.trim());
if (match) {
const day = Number(match[1]);
const month = Number(match[2]);
const year = Number(match[3]);
const parsed = new Date(year, month - 1, day);
// JavaScript rolls an out-of-range day into the next month rather than rejecting it, so
// 31/02/2026 would quietly become the 3rd of March. Reading the parts back catches that.
const isRealDate =
parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day;
return isRealDate ? parsed : null;
}
}
return super.parse(value);
}
override format(date: Date, displayFormat: any): string {
if (displayFormat === 'input') {
return `${this.pad(date.getDate())}/${this.pad(date.getMonth() + 1)}/${date.getFullYear()}`;
}
return super.format(date, displayFormat);
}
private pad(value: number): string {
return value < 10 ? `0${value}` : `${value}`;
}
}
/** Tells the datepicker to render the field itself day-first; the calendar keeps its usual labels. */
export const DDS_DATE_FORMATS: MatDateFormats = {
parse: { dateInput: 'input' },
display: {
dateInput: 'input',
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' }
}
};
@@ -18,6 +18,7 @@ import { OrganizationComponent } from './organization.component';
import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/loan-provisioning-criteria.component';
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 { 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';
@@ -242,8 +243,23 @@ const routes: Routes = [
},
{
path: 'agent-master',
component: AgentMasterComponent,
data: { title: 'Agent Master', breadcrumb: 'Agent Master' }
data: { title: 'Agent Master', breadcrumb: 'Agent Master' },
children: [
{
path: '',
component: AgentMasterComponent
},
{
path: 'create',
component: CreateAgentComponent,
data: { title: 'Create Agent', breadcrumb: 'Create' }
},
{
path: ':agentId/edit',
component: CreateAgentComponent,
data: { title: 'Edit Agent', breadcrumb: 'Edit', routeParamBreadcrumb: false }
}
]
},
{
path: 'commission-slab-master',
@@ -272,6 +272,17 @@ export class OrganizationService {
return this.http.get('/dds/parties');
}
/**
* The parties a given agent collects for — what the daily transaction screen works from, since
* it handles one agent's round at a time.
*
* @param {string | number} agentId Agent ID.
* @returns {Observable<any>} That agent's DDS Parties.
*/
getPartiesOfAgent(agentId: string | number): Observable<any> {
return this.http.get('/dds/parties', { params: new HttpParams().set('agentId', String(agentId)) });
}
/**
* @param {string | number} partyId Party ID.
* @returns {Observable<any>} DDS Party.
@@ -306,6 +317,65 @@ export class OrganizationService {
return this.http.post(`/dds/parties/${partyId}?command=close`, closeCommand);
}
/**
* The DDS accounts whose term has run out and which are still open - the branch's list of closures
* waiting to be done. Nothing closes itself: a matured account is money the bank owes the customer,
* and where it goes has to be agreed with them.
*
* @returns {Observable<any>} Accounts due for closure, oldest maturity first.
*/
getPartiesDueForClosure(): Observable<any> {
return this.http.get('/dds/parties/dueforclosure');
}
/**
* The customer's own accounts a DDS maturity can be paid into - active savings accounts of theirs,
* with fixed and recurring deposits and their other DDS scheme accounts left out.
*
* The server applies the same conditions the payout itself does, so this list can never offer a
* choice that is then refused on save. Filtering here instead meant the two drifted apart: the
* accounts on offer once included a scheme account of a party that had already closed.
*
* @param {string | number} clientId Client ID.
* @returns {Observable<any>} id, accountNo, productName and balance for each.
*/
getCreditableAccounts(clientId: string | number): Observable<any> {
return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } });
}
/**
* Pays a matured DDS account out and closes it. What is paid is what the account holds, not the
* figure quoted at opening.
*
* @param {string | number} partyId Party ID.
* @param {any} payoutCommand { toSavingsAccountNo } - another savings account of the same customer
* to move the money into. Omit it when they have none here: the money is then parked in the
* matured-deposits payable account for the branch to send on to their account at another bank.
* @returns {Observable<any>}
*/
payOutParty(partyId: string | number, payoutCommand: any): Observable<any> {
return this.http.post(`/dds/parties/${partyId}?command=payout`, payoutCommand);
}
/**
* Closes a DDS account before its term is up, and pays out what is left - one act, unlike a
* maturity payout, which leaves the account open for the clerk to close separately.
*
* @param {string | number} partyId Party ID.
* @param {any} closeCommand { shortfallBorneBy, closeDate, dateFormat, locale, toSavingsAccountNo? }.
* closeDate, dateFormat and locale go up exactly as they do on an ordinary close.
* shortfallBorneBy is mandatory
* and is CUSTOMER (their interest is recalculated at the scheme's reduced rate and the
* difference taken back) or AGENT (the customer keeps a full month's interest and the agent
* funds the days left in it). The agent is charged a share of their commission either way.
* toSavingsAccountNo works as it does for a payout. The lock-in, the rate reduction and the
* agent's share come from the scheme and are applied by the server.
* @returns {Observable<any>}
*/
prematureCloseParty(partyId: string | number, closeCommand: any): Observable<any> {
return this.http.post(`/dds/parties/${partyId}?command=prematureclose`, closeCommand);
}
/**
* @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post.
* @returns {Observable<any>}
@@ -314,6 +384,39 @@ export class OrganizationService {
return this.http.post('/dds/partytransactions', transaction);
}
/**
* What an agent wrote down on their round for a date, with what it adds up to.
*
* The counter reads it to count cash against; the agent's own screen shows it back as the record
* of their round. One call for both, so the two can never disagree about the same day.
*/
getAgentCollectionDay(agentId: any, entryDate: string): Observable<any> {
return this.http.get(`/dds/collectionentries?agentId=${agentId}&entryDate=${entryDate}`);
}
/**
* Turns an agent's outstanding entries for a date into real credits - all of them, together.
*
* No amount is sent. What settles is what the entries say, to the rupee: a figure typed at the
* counter would be a second opinion about a total the entries already give.
*/
settleAgentCollections(settlement: any): Observable<any> {
return this.http.post('/dds/collectionentries/settle', settlement);
}
/**
* An agent's DDS transactions. The daily entry screen uses this to show what has already been
* keyed for the agent today, so the same collection book is not entered twice.
*
* @param {string | number} agentId Agent ID.
* @returns {Observable<any>} That agent's transactions.
*/
getPartyTransactionsOfAgent(agentId: string | number): Observable<any> {
return this.http.get('/dds/partytransactions', {
params: new HttpParams().set('agentId', String(agentId))
});
}
/**
* @returns {Observable<any>} DDS Commission Slabs data
*/
@@ -329,6 +432,18 @@ export class OrganizationService {
return this.http.post('/dds/commissionslabs', { slabs });
}
/**
* Corrects one saved commission slab. The backend re-checks the corrected range against the other
* slabs of its type and refuses an overlap.
*
* @param {string | number} slabId Commission slab ID.
* @param {any} slab The corrected slab.
* @returns {Observable<any>} Command result.
*/
updateCommissionSlab(slabId: string | number, slab: any): Observable<any> {
return this.http.put(`/dds/commissionslabs/${slabId}`, slab);
}
/**
* @param {any} batch Batch run filters (jobType, branchScope, jobDate, agentFrom/To, partyFrom/To) to generate a preview for.
* @returns {Observable<any>}
@@ -345,6 +460,24 @@ export class OrganizationService {
return this.http.get(`/dds/batchruns/${batchRunId}`);
}
/**
* @returns {Observable<any>} Every batch run, most recent first — who ran what, and whether it was posted.
*/
getDdsBatchRuns(): Observable<any> {
return this.http.get('/dds/batchruns');
}
/**
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
* collected cash, or are owed commission — distinct from deactivating, which is never refused.
*
* @param {string | number} agentId Agent ID.
* @returns {Observable<any>}
*/
closeAgent(agentId: string | number): Observable<any> {
return this.http.post(`/dds/agents/${agentId}`, {});
}
/**
* @param {string | number} batchRunId Batch run ID to post - applies exactly the rows it previewed.
* @returns {Observable<any>}