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,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'
});
}
});
}
}