DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes

This commit is contained in:
tarunmalviya
2026-10-01 13:08:52 +05:30
parent d47e0860f6
commit 885b39d970
48 changed files with 5878 additions and 374 deletions
@@ -43,7 +43,10 @@
<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 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>
@@ -92,6 +95,18 @@
<!-- 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">
@@ -29,6 +29,10 @@ 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.
@@ -61,11 +65,13 @@ import { OrganizationService } from 'app/organization/organization.service';
})
export class AgentMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
displayedColumns: string[] = [
'agentCode',
'clientName',
'homeBranch',
'glAccount',
'agentCity',
'agentSavingAccountNo',
'agentCurrentBalance',
@@ -103,8 +109,21 @@ export class AgentMasterComponent implements OnInit {
}
private loadAgents(): void {
this.organizationService.getAgents().subscribe((agents: any[]) => {
this.agents = agents || [];
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;
@@ -28,9 +28,45 @@
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID</mat-label>
<input matInput required formControlName="clientId" />
@if (clientLookupFailed) {
<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>
@@ -72,8 +108,7 @@
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- Which branch the agent collects out of. Filled from the client's own office, and
changeable, since an agent can be on the books at one branch and work another. The
<!-- 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">
@@ -111,7 +146,7 @@
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
<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
@@ -122,7 +157,9 @@
{{ 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-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
@@ -148,17 +185,12 @@
<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
required
[max]="maxBirthDate"
[matDatepicker]="birthDatePicker"
formControlName="agentBirthDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="birthDatePicker"></mat-datepicker-toggle>
<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>
@@ -179,10 +211,37 @@
@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</mat-label>
<input matInput required formControlName="clientId" />
<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-error>An agent cannot be its own guarantor</mat-error>
<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">
@@ -244,7 +303,13 @@
<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" (click)="save()">
<button
mat-raised-button
color="primary"
type="button"
[disabled]="agentForm.invalid || clientOtherBranch || !!clientAlreadyAgent || !!clientAgeProblem"
(click)="save()"
>
Save
</button>
</div>
@@ -144,3 +144,48 @@ $primary: #0f766e;
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%);
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormGroup,
@@ -28,9 +28,14 @@ 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 {
@@ -61,7 +66,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
imports: [
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective
RestrictInputDirective,
MatAutocompleteModule
]
})
export class CreateAgentComponent implements OnInit {
@@ -74,21 +80,69 @@ export class CreateAgentComponent implements OnInit {
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 latest date of birth the picker will offer: eighteen years ago today.
*
* It was previously today, which let a clerk save an agent born on the day they were entered —
* the picker opens on today, so a stray Enter was enough. The figure matches the rule the server
* applies, so the calendar cannot offer a date the save would then refuse.
* 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.
*/
maxBirthDate = new Date(new Date().getFullYear() - 18, new Date().getMonth(), new Date().getDate());
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
@@ -103,6 +157,12 @@ export class CreateAgentComponent implements OnInit {
*/
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;
@@ -141,7 +201,11 @@ export class CreateAgentComponent implements OnInit {
agentForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
// A name typed to search with is not a client; only an id picked or keyed saves.
[
Validators.required,
Validators.pattern(/^\d+$/)
]
],
agentCode: [
{
@@ -186,10 +250,8 @@ export class CreateAgentComponent implements OnInit {
'',
Validators.required
],
agentBirthDate: [
'',
Validators.required
],
// Filled from the client, whose date of birth was checked when they were opened.
agentBirthDate: [''],
agentCurrentBalance: [
{
value: 0,
@@ -209,8 +271,39 @@ export class CreateAgentComponent implements OnInit {
});
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) => (this.offices = Array.isArray(offices) ? offices : offices?.pageItems || []),
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 = [])
});
@@ -220,7 +313,9 @@ export class CreateAgentComponent implements OnInit {
// 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)); }
if (clientId) {
this.loadClientSavingsAccounts(String(clientId));
}
},
error: () => (this.ddsProductId = null)
});
@@ -245,11 +340,24 @@ export class CreateAgentComponent implements OnInit {
this.agentForm
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe(() => {
.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));
}
}
});
}
@@ -264,7 +372,9 @@ export class CreateAgentComponent implements OnInit {
'',
[
Validators.required,
this.notTheAgentItself()
Validators.pattern(/^\d+$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
],
guarantorName: [
@@ -288,14 +398,103 @@ export class CreateAgentComponent implements OnInit {
});
// 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((clientId) => this.loadGuarantorClient(group, clientId));
.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.
@@ -312,7 +511,14 @@ export class CreateAgentComponent implements OnInit {
/** 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']) {
for (const controlName of [
'guarantorName',
'telephone',
'address1',
'address2',
'city',
'pinCode'
]) {
group.get(controlName)?.setValue('');
}
if (!clientId) {
@@ -343,11 +549,18 @@ export class CreateAgentComponent implements OnInit {
}
addGuarantor(): void {
this.guarantors.push(this.buildGuarantorGroup());
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. */
@@ -360,6 +573,60 @@ export class CreateAgentComponent implements OnInit {
'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
@@ -370,6 +637,7 @@ export class CreateAgentComponent implements OnInit {
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.
@@ -378,22 +646,42 @@ export class CreateAgentComponent implements OnInit {
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 branch the person is on the bank's books at. Offered as a starting point rather than
// forced, since an agent can be recorded at one office and collect out of another. Set as a
// number rather than through setField, whose String() would stop it matching an option.
if (client?.officeId) {
this.agentForm.get('officeId')?.setValue(Number(client.officeId));
}
// 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));
},
@@ -429,6 +717,11 @@ export class CreateAgentComponent implements OnInit {
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 = [];
@@ -539,10 +832,23 @@ export class CreateAgentComponent implements OnInit {
}))
};
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(() => this.exit());
this.organizationService.updateAgent(this.agentId, payload).subscribe(done);
} else {
this.organizationService.createAgent(payload).subscribe(() => this.exit());
this.organizationService.createAgent(payload).subscribe(done);
}
}
@@ -591,3 +897,33 @@ export class CreateAgentComponent implements OnInit {
}
}
}
/** 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));
}
@@ -15,6 +15,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';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/**
* Commission Slab Master component.
@@ -144,9 +145,9 @@ export class CommissionSlabMasterComponent implements OnInit {
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)
slab.slabType === candidate.slabType &&
Number(candidate.slabFrom) <= Number(slab.slabTo) &&
Number(candidate.slabTo) >= Number(slab.slabFrom)
);
}
@@ -257,8 +258,12 @@ export class CommissionSlabMasterComponent implements OnInit {
locale: 'en'
})
.subscribe({
next: () => {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
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();
},
@@ -268,7 +273,9 @@ export class CommissionSlabMasterComponent implements OnInit {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not correct the slab'
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not correct the slab'
});
}
});
@@ -298,11 +305,20 @@ export class CommissionSlabMasterComponent implements OnInit {
}));
this.organizationService.createCommissionSlabs(payload).subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: `${newSlabs.length} ${newSlabs.length === 1 ? 'slab' : 'slabs'} saved`
});
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
@@ -311,7 +327,9 @@ export class CommissionSlabMasterComponent implements OnInit {
this.alertService.alert({
type: 'Error',
message:
error?.error?.errors?.[0]?.defaultUserMessage || error?.error?.defaultUserMessage || 'Could not save the slabs'
error?.error?.errors?.[0]?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
'Could not save the slabs'
});
}
});
@@ -22,6 +22,7 @@ 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.
@@ -264,11 +265,11 @@ export class DdsBatchProcessingComponent implements OnInit {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. `
+ (isPosting
`${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.'
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
@@ -296,11 +297,21 @@ export class DdsBatchProcessingComponent implements OnInit {
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe(() => {
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',
@@ -312,8 +323,9 @@ export class DdsBatchProcessingComponent implements OnInit {
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.'
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);
});
@@ -339,6 +339,37 @@ export class OrganizationService {
* @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) } });
}
@@ -376,6 +407,14 @@ export class OrganizationService {
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>}
@@ -404,6 +443,26 @@ export class OrganizationService {
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.
@@ -417,6 +476,16 @@ export class OrganizationService {
});
}
/**
* @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
*/