/** * 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 = { Account: 'ACCOUNT', Commission: 'COMMISSION' }; private static readonly SLAB_TYPE_LABELS: Record = { 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' }); } }); } }