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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -27,17 +27,67 @@
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove slab">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row">
@@ -66,11 +116,15 @@
</mat-form-field>
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button
mat-icon-button
color="primary"
type="button"
[disabled]="newSlabForm.invalid"
(click)="addSlab()"
matTooltip="Add slab"
>
@@ -85,6 +139,24 @@
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-coverage__type">{{ entry.slabType }}</span>
<span class="csm-coverage__range">
covers {{ entry.from | number }} to {{ entry.to | number }}
</span>
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
@@ -9,7 +9,8 @@
$primary: #0f766e;
.csm-container {
max-width: 760px;
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
}
@@ -41,6 +42,14 @@ $primary: #0f766e;
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
th:nth-child(1), td:nth-child(1) { width: 26%; }
th:nth-child(2), td:nth-child(2) { width: 22%; }
th:nth-child(3), td:nth-child(3) { width: 22%; }
th:nth-child(4), td:nth-child(4) { width: 18%; }
th:nth-child(5), td:nth-child(5) { width: 12%; }
th {
text-align: left;
@@ -61,6 +70,8 @@ $primary: #0f766e;
.csm-inline-field {
width: 100%;
// Without this the field holds its own 180px floor and pushes the next column out of the card.
min-width: 0;
::ng-deep .mat-mdc-text-field-wrapper {
height: 44px;
@@ -90,3 +101,44 @@ $primary: #0f766e;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
/*
* What the slabs actually cover, and any figure between them that none of them claims.
*
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
*/
.csm-coverage {
margin-top: 14px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(0 0 0 / 2.5%);
display: flex;
flex-direction: column;
gap: 6px;
&__row {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 0.86rem;
}
&__type {
font-weight: 600;
min-width: 90px;
}
&__range {
color: rgb(0 0 0 / 62%);
font-variant-numeric: tabular-nums;
}
&__gap {
color: #b45309;
font-variant-numeric: tabular-nums;
}
}
@@ -14,6 +14,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Commission Slab Master component.
@@ -39,6 +40,7 @@ export class CommissionSlabMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private static readonly SLAB_TYPE_CODES: Record<string, string> = {
Account: 'ACCOUNT',
@@ -99,11 +101,85 @@ export class CommissionSlabMasterComponent implements OnInit {
});
}
/**
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
*
* A gap is not an error - a bank may well decide there is no commission below some floor - but it
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and
* moves on, so the agent is paid nothing for that month with nothing on screen to say why.
*/
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
return this.slabTypes
.map((slabType) => {
const rows = this.slabs
.filter((slab) => slab.slabType === slabType)
.map((slab) => ({ from: Number(slab.slabFrom), to: Number(slab.slabTo) }))
.sort((a, b) => a.from - b.from);
if (rows.length === 0) {
return null;
}
const gaps: string[] = [];
for (let i = 1; i < rows.length; i++) {
const previousEnd = rows[i - 1].to;
const nextStart = rows[i].from;
if (nextStart > previousEnd + 1) {
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`);
}
}
return { slabType, from: rows[0].from, to: rows[rows.length - 1].to, gaps };
})
.filter((entry): entry is { slabType: string; from: number; to: number; gaps: string[] } => entry !== null);
}
/**
* The slab already on the table that this range would collide with, if any.
*
* The backend refuses overlapping slabs, but only when Save is pressed - by then the officer may
* have keyed several rows and has to work out which one it objected to. Catching it as the row is
* added names the clash while it is still the row in front of them.
*/
private clashingSlab(candidate: { slabType: string; slabFrom: number; slabTo: number }): any {
return this.slabs.find(
(slab) =>
slab.slabType === candidate.slabType
&& Number(candidate.slabFrom) <= Number(slab.slabTo)
&& Number(candidate.slabTo) >= Number(slab.slabFrom)
);
}
addSlab(): void {
if (this.newSlabForm.invalid) {
// Marking the controls touched is what makes Material show which one is missing; without it
// the button simply does nothing and the officer has no idea what the screen is waiting for.
this.newSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage first' });
return;
}
this.slabs.push(this.newSlabForm.getRawValue());
const candidate = this.newSlabForm.getRawValue();
if (Number(candidate.slabFrom) > Number(candidate.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(candidate.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
const clash = this.clashingSlab(candidate);
if (clash) {
this.alertService.alert({
type: 'Error',
message: `Overlaps the existing ${clash.slabType} slab ${clash.slabFrom} to ${clash.slabTo}`
});
return;
}
this.slabs.push(candidate);
this.newSlabForm.reset({
slabType: '',
slabFrom: 0,
@@ -112,10 +188,92 @@ export class CommissionSlabMasterComponent implements OnInit {
});
}
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */
removeSlab(index: number): void {
this.slabs.splice(index, 1);
}
/** Which saved row is currently open for correction, or -1 when none is. */
editingIndex = -1;
editSlabForm: UntypedFormGroup = this.formBuilder.group({
slabType: [
'',
Validators.required
],
slabFrom: [
0,
Validators.required
],
slabTo: [
0,
Validators.required
],
commissionPercent: [
0,
Validators.required
]
});
startEdit(index: number): void {
const slab = this.slabs[index];
this.editingIndex = index;
this.editSlabForm.setValue({
slabType: slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
});
}
cancelEdit(): void {
this.editingIndex = -1;
}
saveEdit(): void {
const slab = this.slabs[this.editingIndex];
const corrected = this.editSlabForm.getRawValue();
if (this.editSlabForm.invalid) {
this.editSlabForm.markAllAsTouched();
this.alertService.alert({ type: 'Error', message: 'Fill the slab type, range and percentage' });
return;
}
if (Number(corrected.slabFrom) > Number(corrected.slabTo)) {
this.alertService.alert({ type: 'Error', message: 'Slab From cannot be greater than Slab To' });
return;
}
if (Number(corrected.commissionPercent) > 100) {
this.alertService.alert({ type: 'Error', message: 'Commission cannot be more than 100%' });
return;
}
this.organizationService
.updateCommissionSlab(slab.id, {
slabType: CommissionSlabMasterComponent.SLAB_TYPE_CODES[corrected.slabType] || corrected.slabType,
slabFrom: corrected.slabFrom,
slabTo: corrected.slabTo,
commissionPercent: corrected.commissionPercent,
locale: 'en'
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
this.editingIndex = -1;
this.loadSlabs();
},
// The backend refuses a correction that overlaps another slab; the reason has to reach the
// officer, not vanish.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab'
});
}
});
}
exit(): void {
this.router.navigate(['/organization']);
}
@@ -123,6 +281,12 @@ export class CommissionSlabMasterComponent implements OnInit {
save(): void {
const newSlabs = this.slabs.filter((slab) => !slab.id);
if (newSlabs.length === 0) {
// Save used to return here without a word, so a slab typed into the entry row but never added
// looked exactly like a slab that had been saved.
this.alertService.alert({
type: 'Error',
message: 'Nothing new to save — add the slab to the table with + first'
});
return;
}
@@ -133,6 +297,23 @@ export class CommissionSlabMasterComponent implements OnInit {
commissionPercent: slab.commissionPercent
}));
this.organizationService.createCommissionSlabs(payload).subscribe(() => this.loadSlabs());
this.organizationService.createCommissionSlabs(payload).subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
this.loadSlabs();
},
// The backend refuses an overlapping range; the officer needs to read why, not watch nothing
// happen and assume the save went through.
error: (error: any) => {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs'
});
}
});
}
}