Merge branch 'feature/dds' into merge/dds-into-dev-latest

Brings the DDS (Daily Deposit Scheme) UI from feature/dds onto the
current dev-latest:
- Agent settlement (Agent Tran), Close Party account, DDS reports,
  Party-Savings link field
- Agent collection entry screen, per-agent GL account, maturity auto
  close
- Approvals tab, vouchers, payout & close, Sundry GL search, closure
  tab and fixes
- Menu: DDS Party Master, DDS Accounts, DDS Collection, DDS Reports and
  Locker links placed in the Deposits mega panel groups; Agent Master,
  Commission Slab and DDS Batch Processing under Organization; DDS
  Approvals tab in the checker inbox

No content conflicts. Nothing from dev-latest is removed.

Merge adjustments:
- package-lock.json: kept dev-latest's lock file. feature/dds does not
  add or change any dependency in package.json; its lock file was only
  regenerated (about 18k lines removed and 12k added), which would have
  changed resolved versions for packages already on dev-latest.
- Dropped src/app/deposits/party-master/CBS-Backend.code-workspace, a
  VS Code workspace file holding local folder paths, not app code.
- angular.json: bundle budget raised to 18mb warning / 20mb error, as on
  feature/dds.

Lint fixes required by the pre-commit hook (no behaviour change):
- Prettier formatting on 12 DDS files.
- create-party.component.scss: the dense data-entry section deliberately
  re-targets the base selectors to override them, so
  no-duplicate-selectors is disabled for that section instead of
  merging rules and changing the cascade.
- notice-dialog / create-dds-account-type scss: stylelint-disable-line
  for the "ch" width unit.
- commission-slab-master.component.scss: stylelint auto-fix.

Verified with tsc -p src/tsconfig.app.json --noEmit, stylelint, prettier
and eslint on all merged files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:10:37 +05:30
co-authored by Claude Opus 5.5
84 changed files with 17814 additions and 9 deletions
@@ -0,0 +1,195 @@
<!--
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="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="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>
<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>
<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="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>
@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>
} @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. -->
<!-- The GL the cashier takes this agent's cash into, so it is to hand without opening the agent. -->
<ng-container matColumnDef="glAccount">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let a">
@if (a.glAccountCode) {
<span class="pl-tag">{{ a.glAccountCode }} · {{ a.glAccountName }}</span>
} @else {
<span class="am-no-branch">Not set</span>
}
</td>
</ng-container>
<ng-container matColumnDef="homeBranch">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Branch</th>
<td mat-cell *matCellDef="let a">
@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>
@@ -0,0 +1,59 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Agent-specific indigo accent */
:host {
--pl-accent: #4338ca;
--pl-accent-l: #e0e7ff;
}
/* A retired agent stays listed but reads as switched off. */
.am-row--inactive td:not(.num) {
opacity: 0.55;
}
/* Closing is the last, irreversible step, so it does not read like the buttons beside it. */
.pl-action-btn--close {
color: #9b2c2c;
}
/* 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-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-pill--off {
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 52%);
}
/* 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;
}
@@ -0,0 +1,147 @@
/**
* 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, 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 { AuthenticationService } from 'app/core/authentication/authentication.service';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { officesUnder } from './offices-under';
/**
* Agent Master list.
*
* 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,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class AgentMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
displayedColumns: string[] = [
'agentCode',
'clientName',
'homeBranch',
'glAccount',
'agentCity',
'agentSavingAccountNo',
'agentCurrentBalance',
'status',
'createdDate',
'actions'
];
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 {
const loginOfficeId = this.authenticationService.getCredentials()?.officeId;
forkJoin({
agents: this.organizationService.getAgents(),
offices: this.organizationService.getOffices().pipe(catchError(() => of([])))
}).subscribe(({ agents, offices }: any) => {
// The branch logged in to sees its own agents and those of every branch under it - Head Office,
// at the top, sees them all.
const visible = new Set(
officesUnder(Array.isArray(offices) ? offices : offices?.pageItems || [], loginOfficeId).map(
(office: any) => office.id
)
);
this.agents = (agents || []).filter(
(agent: any) => !loginOfficeId || visible.size === 0 || visible.has(agent.officeId)
);
this.dataSource = new MatTableDataSource(this.agents);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
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
});
money(value: any): string {
return AgentMasterComponent.MONEY.format(Number(value || 0));
}
}
@@ -0,0 +1,318 @@
<!--
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 or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
#clientInput
#clientTrigger="matAutocompleteTrigger"
(focus)="onClientFocus()"
placeholder="Click to see clients, or type a name or ID…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of clientOptions; track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
@if (clientListIsPartial) {
<mat-option disabled class="am-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (agentForm.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
} @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) {
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
} @else if (clientOtherBranch) {
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
} @else if (loadedClientName) {
<mat-hint>{{ loadedClientName }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Code</mat-label>
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The
option carries the office's id while showing its name, so renaming a branch later
leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>GL Account</mat-label>
<mat-select required formControlName="glAccountId">
@for (account of glAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
}
</mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ loadedClientName }} has no savings account — commission can only be paid into one
</mat-hint>
} @else {
<mat-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
<mat-datepicker #openingDatePicker></mat-datepicker>
</mat-form-field>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-user-shield"></i>
<h3>Guarantor's Detail</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
</button>
</div>
@if (guarantors.length === 0) {
<p class="am-empty-guarantors">No guarantors added yet.</p>
}
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="guarantorAuto"
#guarantorInput
#guarantorTrigger="matAutocompleteTrigger"
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
placeholder="Click to see clients, or type…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of guarantorOptionsFor(guarantor); track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
</mat-autocomplete>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
}
</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 || clientOtherBranch || !!clientAlreadyAgent || !!clientAgeProblem"
(click)="save()"
>
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,191 @@
/**
* 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);
}
}
/* One row of the client search list: the id, the name, and the branch - as on the Party form. */
.am-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
.am-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.am-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: #0f766e;
}
.am-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
.am-search-ic {
margin-right: 8px;
color: rgb(0 0 0 / 40%);
}
@@ -0,0 +1,929 @@
/**
* 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 { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, 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';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search';
import { officesUnder } from '../offices-under';
/** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
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,
MatAutocompleteModule
]
})
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);
private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
private changeDetector = inject(ChangeDetectorRef);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
/**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
* Branch list offers only it, and only its clients can be picked.
*/
private readonly loginOfficeId: number | null = this.authenticationService.getCredentials()?.officeId ?? null;
/**
* The branch the agent is being opened for - the one picked in Home Branch, the login branch until
* then. Its clients are the ones offered, since an agent collects for the branch they belong to.
*/
private get branchOfficeId(): number | null {
return Number(this.agentForm?.get('officeId')?.value) || this.loginOfficeId;
}
/** Set when the client keyed belongs to another branch, which cannot be made an agent here. */
clientOtherBranch = false;
/**
* The code of the agent the keyed client already is, if any. One client is one agent, so they are
* left out of the list and refused when typed.
*/
clientAlreadyAgent = '';
private agentCodeByClientId: Record<string, string> = {};
/** Why the picked client's age rules them out as an agent, or empty when it does not. */
clientAgeProblem = '';
/** The ages an agent is taken on at, matching the rule the server applies. */
private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60;
/** The agent's client list without the clients who are already agents. */
private notYetAgents(clients: any[]): any[] {
return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]);
}
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
loadedClientName = '';
clientLookupFailed = false;
/**
* Clients to pick from, as on the Party form: the first page when the field is focused, then
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id.
*/
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
/**
* The client's own open savings accounts, fetched once the client is resolved. The commission
* account is picked from these rather than typed, so it is always an account that exists and
* 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[] = [];
/**
* Each type's product code, which is what the dropdown shows. The type is still saved by its name,
* as before, so what other screens and reports read is unchanged.
*/
savingsAccountTypeCodes: Record<string, string> = {};
/** Fineract's deposit-account kinds: 100 savings, 200 fixed deposit, 300 recurring deposit. */
private static readonly SAVINGS_DEPOSIT_TYPE = 100;
/** 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: [
'',
// A name typed to search with is not a client; only an id picked or keyed saves.
[
Validators.required,
Validators.pattern(/^\d+$/)
]
],
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
],
// Filled from the client, whose date of birth was checked when they were opened.
agentBirthDate: [''],
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 {
// A different branch has different clients: the list is read again, and a client already keyed
// is checked against the new branch.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
});
// Who is already an agent, so they are neither offered nor accepted as a new one.
this.organizationService.getAgents().subscribe({
next: (agents: any[]) => {
this.agentCodeByClientId = {};
for (const agent of agents || []) {
if (agent.active !== false && agent.clientId != null) {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
},
error: () => (this.agentCodeByClientId = {})
});
this.organizationService.getOffices().subscribe({
next: (offices: any) => {
const all = Array.isArray(offices) ? offices : offices?.pageItems || [];
// The branch logged in to and every branch under it - Head Office, at the top, sees them all.
this.offices = officesUnder(all, this.loginOfficeId);
if (!this.agentId && this.offices.some((office: any) => office.id === this.loginOfficeId)) {
this.agentForm.get('officeId')?.setValue(this.loginOfficeId);
}
},
error: () => (this.offices = [])
});
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((value) => {
const typed = String(value ?? '').trim();
// A name is something to search for, not a client yet - the fields stay until one is picked.
if (typed && !/^\d+$/.test(typed)) {
this.searchClients(typed);
return;
}
if (!typed) {
this.showClientList();
}
this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity();
// And the agent's own client leaves (or rejoins) each row's list.
if (!row.get('clientId')?.value) {
this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients));
}
}
});
}
get guarantors(): UntypedFormArray {
return this.agentForm.get('guarantors') as UntypedFormArray;
}
private buildGuarantorGroup(): UntypedFormGroup {
const group = this.formBuilder.group({
clientId: [
'',
[
Validators.required,
Validators.pattern(/^\d+$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
],
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.
// Searched by name like the agent's own client field; a picked or keyed id fills the row.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
if (typed && !/^\d+$/.test(typed)) {
searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
});
return;
}
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed);
if (!typed) {
// Emptied: the list comes back rather than leaving nothing to pick from.
this.onGuarantorFocus(group);
}
// Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) {
if (row !== group) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
});
return group;
}
/**
* Each guarantor row's list lives on the row itself, as a plain array the template reads - the same
* way the agent's own list works - so one row's typing never fills another's.
*/
private setGuarantorOptions(group: AbstractControl, options: any[]): void {
(group as any).clientOptions = options;
}
guarantorOptionsFor(group: AbstractControl): any[] {
return (group as any).clientOptions || GUARANTOR_NO_OPTIONS;
}
/**
* Opens the client list on a guarantor row as soon as it is focused, as the agent's field does.
* The list is filled before the panel is asked to open, since a panel opened empty stays shut.
*/
onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
if (trigger) {
// Kept so a search answered later can open this row's list too.
(group as any).panel = { trigger, input };
}
if (group.get('clientId')?.value) {
return;
}
const show = () => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
};
if (this.initialClients.length > 0) {
show();
return;
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
show();
},
error: () => undefined
});
}
/** Clients who can stand guarantor on this row: not the agent, and not already on another row. */
private eligibleGuarantors(group: AbstractControl, found: any[]): any[] {
const agentClientId = String(this.agentForm.get('clientId')?.value ?? '');
const taken = new Set(
this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? ''))
);
return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id)));
}
/** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value || !this.agentForm) {
return null;
}
const repeated = this.guarantors.controls.some(
(row) => row.get('clientId') !== control && String(row.get('clientId')?.value) === String(control.value)
);
return repeated ? { duplicateGuarantor: true } : null;
};
}
/**
* An agent cannot stand guarantor for itself - a guarantee is only worth something if it comes
* from someone else.
*/
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 {
const group = this.buildGuarantorGroup();
this.guarantors.push(group);
// Ready before the field is ever focused, so the list is there the moment it is clicked.
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
}
removeGuarantor(index: number): void {
this.guarantors.removeAt(index);
// A row that clashed with the removed one no longer does.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity({ emitEvent: false });
}
}
/** The fields the legacy screen filled from the client, rather than asking the officer for. */
private static readonly CLIENT_DERIVED_FIELDS = [
'agentNameId',
'agentTelephone',
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode'
];
/** Opens the client list when the field is focused, unless a client is already in it. */
onClientFocus(): void {
if (this.agentId || this.agentForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the first page of clients, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.agentId) {
return;
}
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
return;
}
// The chosen branch's clients only, since an agent is opened for the branch they belong to.
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
},
// A failed list is not a failed screen - the id can still be typed.
error: () => (this.clientOptions = [])
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = this.notYetAgents(found).slice(0, 25);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
});
}
/**
* Shows a list that has just arrived. An autocomplete opened while its list was still empty stays
* shut when the options come in, so the screen is refreshed and the panel opened again - but only
* on the field still being typed in, never one the officer has moved on from.
*/
private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
this.changeDetector.detectChanges();
if (trigger && input && document.activeElement === input) {
trigger.openPanel();
}
}
/**
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
* They always show the client currently entered: changing the id replaces them, clearing it
* empties them.
*/
loadClient(): void {
if (this.agentId) {
return;
}
this.clearClientDerivedFields();
this.agentForm.get('agentBirthDate')?.setValue('');
this.loadedClientName = '';
this.clientLookupFailed = false;
// A different client has different accounts, so anything picked for the previous one is stale.
this.clientSavingsAccounts = [];
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
this.clientAgeProblem = '';
const clientId = this.agentForm.get('clientId')?.value;
if (!clientId) {
return;
}
// One client is one agent: someone already on the books is not made a second agent.
this.clientAlreadyAgent = this.agentCodeByClientId[String(clientId)] || '';
if (this.clientAlreadyAgent) {
return;
}
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
// An agent is opened at the branch chosen, from that branch's own clients.
if (this.branchOfficeId && client?.officeId && Number(client.officeId) !== this.branchOfficeId) {
this.clientOtherBranch = true;
this.loadedClientName = '';
return;
}
this.loadedClientName = client?.displayName || '';
this.setField('agentNameId', client?.displayName);
this.setField('agentTelephone', client?.mobileNo);
// The client's own date of birth, already taken and checked when the client was opened -
// copied across rather than keyed again.
const birthDate = asDate(client?.dateOfBirth);
this.agentForm.get('agentBirthDate')?.setValue(birthDate);
// Agents are taken on at 16 to 59 - said here, before the rest is filled in, not on Save.
const age = ageOn(birthDate, new Date());
this.clientAgeProblem =
age === null
? 'This client has no date of birth on record'
: age < CreateAgentComponent.MIN_AGENT_AGE || age >= CreateAgentComponent.MAX_AGENT_AGE
? `Age is ${age}. An agent must be ${CreateAgentComponent.MIN_AGENT_AGE} to ${CreateAgentComponent.MAX_AGENT_AGE - 1} years old`
: '';
this.loadClientAddress(String(clientId));
this.loadClientSavingsAccounts(String(clientId));
},
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))
];
for (const account of this.clientSavingsAccounts) {
if (account.productName && account.shortProductName) {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
}
}
},
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
}))
};
const done = (response: any) => {
// Under maker-checker the command is only recorded, and nothing has been saved yet.
if (!!response?.commandId && response?.resourceId == null) {
this.alertService.alert({
type: 'Sent for Approval',
message: this.agentId
? `Changes to agent ${raw.agentNameId} were sent for a manager's approval and will take effect once approved.`
: `New agent ${raw.agentNameId} was sent for a manager's approval and will be created once approved.`
});
}
this.exit();
};
if (this.agentId) {
this.organizationService.updateAgent(this.agentId, payload).subscribe(done);
} else {
this.organizationService.createAgent(payload).subscribe(done);
}
}
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);
}
}
}
/** A date as the platform sends it - [yyyy, mm, dd] or an ISO string - or null when there is none. */
function asDate(value: any): Date | null {
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
if (value) {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
return null;
}
/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */
const GUARANTOR_NO_OPTIONS: any[] = [];
/** Full years between a date of birth and a day, or null without a date of birth. */
function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) {
return null;
}
let age = on.getFullYear() - birthDate.getFullYear();
const birthdayPassed =
on.getMonth() > birthDate.getMonth() ||
(on.getMonth() === birthDate.getMonth() && on.getDate() >= birthDate.getDate());
if (!birthdayPassed) {
age--;
}
return age;
}
@@ -0,0 +1,27 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The given office and every office under it, read from the office hierarchy (".", ".2.", ".2.5."):
* an office's own branches are the ones whose hierarchy starts with its own. Head Office, at the
* top, has them all. Falls back to the office alone when its hierarchy is not known.
*/
export function officesUnder(offices: any[], officeId: number | null | undefined): any[] {
if (!officeId) {
return offices;
}
const own = offices.find((office: any) => office.id === officeId);
if (!own) {
return [];
}
const hierarchy = String(own.hierarchy || '');
if (!hierarchy) {
return [own];
}
return offices.filter((office: any) => String(office.hierarchy || '').startsWith(hierarchy));
}
@@ -0,0 +1,157 @@
<!--
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="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="csm-header__title">Commission Slab Master</h2>
</div>
<div class="csm-card">
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th>Slab From</th>
<th>Slab To</th>
<th>Comm Per</th>
<th></th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
@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">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@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 required type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="commissionPercent" />
</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" (click)="addSlab()" matTooltip="Add slab">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (slabs.length === 0) {
<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>
</div>
</div>
</div>
@@ -0,0 +1,163 @@
/**
* 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;
.csm-container {
// 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;
}
.csm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.csm-back {
color: $primary;
}
.csm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 16px 24px 24px;
}
.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;
font-size: 13px;
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%);
}
td {
padding: 6px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: middle;
}
}
.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;
}
}
.csm-row-actions {
text-align: center;
width: 48px;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 12px 0 0;
}
.csm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
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;
}
}
@@ -0,0 +1,337 @@
/**
* 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, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Commission Slab Master component.
*
* Two kinds of slab: ACCOUNT (commission percentage on collection volume)
* and COMMISSION (a senior-agent override on another agent's commission -
* deferred: there is no agent hierarchy modelled yet, see the DDS punch
* list). Modelled directly on the legacy Commission Slab Master screen this
* replaces.
*
* Backed by its own DDS Commission Slab API (`/dds/commissionslabs`) - an
* append-only chart, same as the legacy screen: existing rows load on open,
* newly-added rows post on Save, and there is no edit/delete of a
* previously-saved row.
*/
@Component({
selector: 'mifosx-commission-slab-master',
templateUrl: './commission-slab-master.component.html',
styleUrls: ['./commission-slab-master.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
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',
Commission: 'COMMISSION'
};
private static readonly SLAB_TYPE_LABELS: Record<string, string> = {
ACCOUNT: 'Account',
COMMISSION: 'Commission'
};
slabTypes = [
'Account',
'Commission'
];
displayedColumns: string[] = [
'slabType',
'slabFrom',
'slabTo',
'commissionPercent',
'actions'
];
slabs: Array<{ id?: number; slabType: string; slabFrom: number; slabTo: number; commissionPercent: number }> = [];
newSlabForm: UntypedFormGroup = this.formBuilder.group({
slabType: [
'',
Validators.required
],
slabFrom: [
0,
Validators.required
],
slabTo: [
0,
Validators.required
],
commissionPercent: [
0,
Validators.required
]
});
ngOnInit(): void {
this.loadSlabs();
}
private loadSlabs(): void {
this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => {
this.slabs = (slabs || []).map((slab) => ({
id: slab.id,
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
});
}
/**
* 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;
}
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,
slabTo: 0,
commissionPercent: 0
});
}
/** 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: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService);
} else {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
}
this.editingIndex = -1;
this.loadSlabs();
},
// 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']);
}
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;
}
const payload = newSlabs.map((slab) => ({
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
this.organizationService.createCommissionSlabs(payload).subscribe({
next: (response: any) => {
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'}`,
this.organizationService,
this.alertService
);
} else {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
}
this.loadSlabs();
},
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
// 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'
});
}
});
}
}
@@ -0,0 +1,299 @@
<!--
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="bp-container">
<div class="bp-header">
<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</h2>
</div>
<div class="bp-card">
<form [formGroup]="batchForm">
<div class="bp-grid">
<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.code) {
<mat-option [value]="type.code">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Branch</mat-label>
<mat-select required formControlName="branchScope">
@for (scope of branchScopes; track scope) {
<mat-option [value]="scope">{{ scope }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isBranchSelected) {
<div class="bp-field bp-field--wide bp-branch-checklist">
@for (office of offices; track office.id) {
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
{{ office.name }}
</mat-checkbox>
}
@if (offices.length === 0) {
<span class="bp-empty">Loading branches...</span>
}
</div>
}
<mat-form-field appearance="outline" class="bp-field">
<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>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent From</mat-label>
<input matInput required type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent To</mat-label>
<input matInput required type="number" formControlName="agentTo" />
</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>
}
</div>
<div class="bp-callout">
<i class="fas fa-info-circle"></i>
<span>
@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 (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<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">
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<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-calculator"></i> Generate
</button>
<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>
@@ -0,0 +1,255 @@
/**
* 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;
.bp-container {
// 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;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.bp-back {
color: $primary;
}
.bp-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.bp-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.bp-field {
width: 100%;
&--wide {
grid-column: span 3;
}
}
.bp-branch-checklist {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
align-items: center;
padding: 8px 0;
}
.bp-callout {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgb(15 118 110 / 6%);
border-left: 3px solid $primary;
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 16px 0;
font-size: 13px;
i {
color: $primary;
margin-top: 2px;
}
}
.bp-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
i {
color: $primary;
}
h3 {
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 {
color: rgb(0 0 0 / 50%);
font-size: 14px;
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: 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: 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;
}
}
.bp-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
i {
margin-right: 6px;
}
}
@media (width <= 900px) {
.bp-grid {
grid-template-columns: repeat(2, 1fr);
}
.bp-field--wide {
grid-column: span 2;
}
}
@@ -0,0 +1,400 @@
/**
* 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, 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';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
*
* 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.
*
* 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,
// 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);
/**
* 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 = [
'All',
'Selected'
];
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',
Validators.required
],
branchScope: [
'All',
Validators.required
],
jobDate: [
new Date(),
Validators.required
],
agentFrom: [
1,
Validators.required
],
agentTo: [
99999,
Validators.required
],
partyFrom: [
1,
Validators.required
],
partyTo: [
99999999,
Validators.required
]
});
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.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 {
if (this.selectedOfficeNames.has(officeName)) {
this.selectedOfficeNames.delete(officeName);
} else {
this.selectedOfficeNames.add(officeName);
}
}
/**
* 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 dateFormat = this.settingsService.dateFormat;
const payload: any = {
jobType: raw.jobType,
branchScope: raw.branchScope,
jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
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.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.generatedRun || this.previewRows.length === 0) {
return;
}
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((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
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,70 @@
/**
* 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' }
}
};
@@ -17,6 +17,10 @@ import { Route } from '../core/route/route.service';
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';
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
@@ -237,6 +241,36 @@ const routes: Routes = [
}
]
},
{
path: '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',
component: CommissionSlabMasterComponent,
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' }
},
{
path: 'dds-batch-processing',
component: DdsBatchProcessingComponent,
data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' }
},
{
// Bank's own profile — name, legal name, code, registration, address/contact. A
// singleton, unlike every other screen under Organization, so it has no create/list step.
@@ -190,6 +190,47 @@
</a>
</div>
<div #agentMaster>
<a class="org-card" [routerLink]="['agent-master']">
<div class="org-card__icon org-card__icon--teal">
<i class="fas fa-user-tie"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Agent Master</h3>
<p class="org-card__desc">
Register Daily Deposit Scheme collection agents, their commission/TDS settings, limits and guarantors
</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<div #commissionSlabMaster>
<a class="org-card" [routerLink]="['commission-slab-master']">
<div class="org-card__icon org-card__icon--teal">
<i class="fas fa-percentage"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Commission Slab Master</h3>
<p class="org-card__desc">Define collection-volume and override commission percentage slabs</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<div #ddsBatchProcessing>
<a class="org-card" [routerLink]="['dds-batch-processing']">
<div class="org-card__icon org-card__icon--teal">
<i class="fas fa-cogs"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">DDS Interest &amp; Commission Batch</h3>
<p class="org-card__desc">Calculate then post interest, OD interest, and commission for DDS accounts</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Manage Loan Originators -->
<div #manageLoanOriginators>
<a class="org-card" [routerLink]="['manage-loan-originators']" *mifosxHasPermission="'READ_LOAN_ORIGINATOR'">
@@ -233,6 +233,343 @@ export class OrganizationService {
return this.http.get('/currencies');
}
/**
* @returns {Observable<any>} DDS Agents data
*/
getAgents(): Observable<any> {
return this.http.get('/dds/agents');
}
/**
* @param {string | number} agentId Agent ID.
* @returns {Observable<any>} DDS Agent.
*/
getAgent(agentId: string | number): Observable<any> {
return this.http.get(`/dds/agents/${agentId}`);
}
/**
* @param {any} agent Agent to be created.
* @returns {Observable<any>}
*/
createAgent(agent: any): Observable<any> {
return this.http.post('/dds/agents', agent);
}
/**
* @param {string | number} agentId Agent ID.
* @param {any} agent Agent changes to apply.
* @returns {Observable<any>}
*/
updateAgent(agentId: string | number, agent: any): Observable<any> {
return this.http.put(`/dds/agents/${agentId}`, agent);
}
/**
* @returns {Observable<any>} DDS Parties data
*/
getParties(): Observable<any> {
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.
*/
getParty(partyId: string | number): Observable<any> {
return this.http.get(`/dds/parties/${partyId}`);
}
/**
* @param {any} party Party to be created.
* @returns {Observable<any>}
*/
createParty(party: any): Observable<any> {
return this.http.post('/dds/parties', party);
}
/**
* @param {string | number} partyId Party ID.
* @param {any} party Party changes to apply.
* @returns {Observable<any>}
*/
updateParty(partyId: string | number, party: any): Observable<any> {
return this.http.put(`/dds/parties/${partyId}`, party);
}
/**
* @param {string | number} partyId Party ID.
* @param {any} closeCommand { closeDate }
* @returns {Observable<any>}
*/
closeParty(partyId: string | number, closeCommand: any): Observable<any> {
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.
*/
/** The liability GL accounts a DDS balance can be parked in, for the clerk to search and pick. */
getDdsSundryAccounts(): Observable<any> {
return this.http.get('/dds/parties/sundryaccounts');
}
/** DDS entries waiting on (or already past) a checker - PENDING, APPROVED or REJECTED. */
getDdsApprovals(status: string, fromDate?: string, toDate?: string): Observable<any> {
const params: any = { status };
if (fromDate) {
params.fromDate = fromDate;
}
if (toDate) {
params.toDate = toDate;
}
return this.http.get('/dds/approvals', { params });
}
getDdsApproval(commandId: string | number): Observable<any> {
return this.http.get(`/dds/approvals/${commandId}`);
}
/** Approves or rejects a DDS entry; an approval returns the voucher it moved under. */
actOnDdsApproval(commandId: string | number, command: 'approve' | 'reject'): Observable<any> {
return this.http.post(`/dds/approvals/${commandId}`, {}, { params: { command } });
}
/** How a closed DDS account was closed: by whom, what it came to and where the money went. */
getPartyClosure(partyId: string | number): Observable<any> {
return this.http.get(`/dds/parties/${partyId}/closure`);
}
getCreditableAccounts(clientId: string | number): Observable<any> {
return this.http.get('/dds/parties/creditaccounts', { params: { clientId: String(clientId) } });
}
/**
* 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);
}
/**
* Closes a DDS account early inside its scheme's lock-in, for the reason given. Its own permission,
* so it goes to a manager for approval; the scheme's lock-in is not changed.
*/
prematureCloseOverrideParty(partyId: string | number, closeCommand: any): Observable<any> {
return this.http.post(`/dds/parties/${partyId}?command=prematurecloseoverride`, closeCommand);
}
/**
* @param {any} transaction Daily transaction batch (PARTY_TRAN collections or a WITHDRAW_DEPOSITE transfer) to post.
* @returns {Observable<any>}
*/
createPartyTransaction(transaction: any): Observable<any> {
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);
}
/** Everything the agent still has outstanding, whatever day it was written - what a receipt is applied against. */
getAgentPendingCollections(agentId: any): Observable<any> {
return this.http.get(`/dds/collectionentries/pending?agentId=${agentId}`);
}
/**
* The cashier's receipts into the agent's GL account: the DDS receipts ready to apply, or - with a
* voucher number - that one voucher in whatever state, so the screen can say why it cannot be used.
*/
getAgentCashReceipts(agentId: any, voucherNo?: string, includeWaiting = false): Observable<any> {
const voucher = voucherNo ? `&voucherNo=${encodeURIComponent(voucherNo)}` : '';
const waiting = includeWaiting ? '&includeWaiting=true' : '';
return this.http.get(`/dds/collectionentries/cashreceipts?agentId=${agentId}${voucher}${waiting}`);
}
/** A cashier receipt's own Dr/Cr lines. */
getCashReceiptGlEntries(receiptId: any): Observable<any> {
return this.http.get(`/dds/collectionentries/cashreceipts/${receiptId}/glentries`);
}
/**
* An agent's DDS transactions. The daily entry screen uses this to show what has already been
* keyed for the agent today, so the same collection book is not entered twice.
*
* @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))
});
}
/**
* @param {string | number} partyId DDS account (party) id.
* @returns {Observable<any>} Everything posted to that DDS account, most recent first.
*/
getPartyTransactionsOfParty(partyId: string | number): Observable<any> {
return this.http.get('/dds/partytransactions', {
params: new HttpParams().set('partyId', String(partyId))
});
}
/**
* @returns {Observable<any>} DDS Commission Slabs data
*/
getCommissionSlabs(): Observable<any> {
return this.http.get('/dds/commissionslabs');
}
/**
* @param {any[]} slabs New commission slab rows to add to the chart.
* @returns {Observable<any>}
*/
createCommissionSlabs(slabs: any[]): Observable<any> {
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>}
*/
generateDdsBatchRun(batch: any): Observable<any> {
return this.http.post('/dds/batchruns', batch);
}
/**
* @param {string | number} batchRunId Batch run ID.
* @returns {Observable<any>} Batch run, including its previewed/posted rows.
*/
getDdsBatchRun(batchRunId: string | number): Observable<any> {
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>}
*/
postDdsBatchRun(batchRunId: string | number): Observable<any> {
return this.http.post(`/dds/batchruns/${batchRunId}`, {});
}
/**
* @param {string} reportKey One of the backend DDS report keys.
* @param {any} filters fromDate, toDate (ISO strings), agentFrom/To, partyFrom/To.
* @returns {Observable<any>} { reportKey, available, message, columns, rows }
*/
runDdsReport(reportKey: string, filters: any): Observable<any> {
let httpParams = new HttpParams();
Object.keys(filters || {}).forEach((key) => {
if (filters[key] !== undefined && filters[key] !== null && filters[key] !== '') {
httpParams = httpParams.set(key, filters[key]);
}
});
return this.http.get(`/dds/reports/${reportKey}`, { params: httpParams });
}
/**
* @param {any[]} currencies
* @returns {Observable<any>} Currencies data