338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
/**
|
|
* 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'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|