930 lines
35 KiB
TypeScript
930 lines
35 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 { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
|
|
import {
|
|
UntypedFormBuilder,
|
|
UntypedFormGroup,
|
|
UntypedFormArray,
|
|
Validators,
|
|
ValidatorFn,
|
|
AbstractControl,
|
|
ValidationErrors
|
|
} from '@angular/forms';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
/** Custom Services */
|
|
import { OrganizationService } from 'app/organization/organization.service';
|
|
import { AccountingService } from 'app/accounting/accounting.service';
|
|
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 {
|
|
if (value === null || value === undefined || value === '') {
|
|
return;
|
|
}
|
|
group.get(controlName)?.setValue(String(value));
|
|
}
|
|
|
|
/**
|
|
* Agent Master Entry component.
|
|
*
|
|
* A DDS collection agent — either a person or an institution — registered with its own settlement
|
|
* savings account and one or more guarantors. Modelled on the legacy Agent Master Entry screen, field
|
|
* for field.
|
|
*
|
|
* Entering a Client ID fills the name, address, city, pincode and telephone from that client, the way
|
|
* the legacy screen did, defaults the home branch to that client's office, and stocks the account
|
|
* type and account number dropdowns from that client's own accounts. Filled fields stay editable.
|
|
*
|
|
* Backed by its own DDS Agent API (`/dds/agents`) — independent of Staff/Client CRUD, per the DDS
|
|
* agent/commission hierarchy being genuinely new feature work.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-create-agent',
|
|
templateUrl: './create-agent.component.html',
|
|
styleUrls: ['./create-agent.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
// See the Party form: an unimported directive is ignored without a word.
|
|
RestrictInputDirective,
|
|
MatAutocompleteModule
|
|
]
|
|
})
|
|
export class CreateAgentComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private router = inject(Router);
|
|
private route = inject(ActivatedRoute);
|
|
private organizationService = inject(OrganizationService);
|
|
private clientsService = inject(ClientsService);
|
|
private accountingService = inject(AccountingService);
|
|
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 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.
|
|
*/
|
|
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
|
|
* always belongs to this agent.
|
|
*/
|
|
clientSavingsAccounts: any[] = [];
|
|
|
|
/**
|
|
* The account types this client actually holds, taken from their own accounts rather than a fixed
|
|
* list. A fixed list would offer types the client has nothing under, and its wording would have to
|
|
* match the product names by hand for the filter below to work at all.
|
|
*/
|
|
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;
|
|
|
|
/** Branches, for the Home Branch field. Defaulted from the client's own office. */
|
|
offices: any[] = [];
|
|
|
|
/**
|
|
* The ledger accounts an agent's collections may pass through. Header accounts are left out: they
|
|
* are headings over other accounts and carry no entries, so offering one only leads to a refusal.
|
|
*/
|
|
glAccounts: any[] = [];
|
|
|
|
/**
|
|
* The savings product a DDS customer's own scheme account is opened under, read from the setting
|
|
* that names it rather than guessed from a product name a bank is free to change.
|
|
*
|
|
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
|
|
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
|
|
* counted as that customer's deposits by every interest run afterwards. The server refuses it
|
|
* either way - this is so the choice is never offered rather than taken and then thrown back.
|
|
*/
|
|
private ddsProductId: number | null = null;
|
|
|
|
/**
|
|
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
|
|
* did, so the officer narrows to a kind of account and then picks one of that kind.
|
|
*/
|
|
get accountsOfChosenType(): any[] {
|
|
const chosenType = this.agentForm?.get('agentSavingAccountType')?.value;
|
|
if (!chosenType) {
|
|
return [];
|
|
}
|
|
return this.clientSavingsAccounts.filter((account: any) => account.productName === chosenType);
|
|
}
|
|
|
|
agentForm: UntypedFormGroup = this.formBuilder.group({
|
|
clientId: [
|
|
'',
|
|
// A name typed to search with is not a client; only an id picked or keyed saves.
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(/^\d+$/)
|
|
]
|
|
],
|
|
agentCode: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
agentNameId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentAddress1: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentAddress2: [''],
|
|
agentCity: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentPinCode: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentTelephone: [''],
|
|
// Which branch this agent collects out of. Holds the office's id, not its name - the name is
|
|
// only what the dropdown shows. The DDS batch runs filter by it.
|
|
officeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
// Asked before the account number, which is then narrowed to accounts of this type.
|
|
agentSavingAccountType: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentSavingAccountNo: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentOpeningDate: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
// Filled from the client, whose date of birth was checked when they were opened.
|
|
agentBirthDate: [''],
|
|
agentCurrentBalance: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
agentOpeningBalance: [0],
|
|
/*
|
|
* Required. An agent sharing the scheme's one account leaves a balance that says money is
|
|
* outstanding without saying whose - which is exactly what this field was added to answer.
|
|
*/
|
|
glAccountId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
guarantors: this.formBuilder.array([])
|
|
});
|
|
|
|
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) => {
|
|
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 = [])
|
|
});
|
|
|
|
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
|
|
next: (config: any) => {
|
|
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
|
|
// 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));
|
|
}
|
|
},
|
|
error: () => (this.ddsProductId = null)
|
|
});
|
|
|
|
this.accountingService.getChartOfAccounts().subscribe({
|
|
next: (accounts: any) => {
|
|
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
|
|
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
|
|
// agent pointed at one would fail on their first collection rather than here.
|
|
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
|
|
},
|
|
error: () => (this.glAccounts = [])
|
|
});
|
|
|
|
// An id in the route means this is an edit; load that agent into the form.
|
|
const routeId = this.route.snapshot.paramMap.get('agentId');
|
|
if (routeId) {
|
|
this.organizationService.getAgent(routeId).subscribe((agent: any) => this.patchFormFromAgent(agent));
|
|
}
|
|
|
|
// Fill the client-derived fields as soon as typing settles - no need to tab out of the field.
|
|
this.agentForm
|
|
.get('clientId')
|
|
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
|
|
.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));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
get guarantors(): UntypedFormArray {
|
|
return this.agentForm.get('guarantors') as UntypedFormArray;
|
|
}
|
|
|
|
private buildGuarantorGroup(): UntypedFormGroup {
|
|
const group = this.formBuilder.group({
|
|
clientId: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(/^\d+$/),
|
|
this.notTheAgentItself(),
|
|
this.notAlreadyAGuarantor()
|
|
]
|
|
],
|
|
guarantorName: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
address1: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
address2: [''],
|
|
city: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
pinCode: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
telephone: ['']
|
|
});
|
|
|
|
// 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((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.
|
|
*/
|
|
private notTheAgentItself(): ValidatorFn {
|
|
return (control: AbstractControl): ValidationErrors | null => {
|
|
const agentClientId = this.agentForm?.get('clientId')?.value;
|
|
if (!control.value || !agentClientId) {
|
|
return null;
|
|
}
|
|
return String(control.value) === String(agentClientId) ? { selfGuarantor: true } : null;
|
|
};
|
|
}
|
|
|
|
/** 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'
|
|
]) {
|
|
group.get(controlName)?.setValue('');
|
|
}
|
|
if (!clientId) {
|
|
return;
|
|
}
|
|
|
|
this.clientsService.getClientData(String(clientId), true).subscribe({
|
|
next: (client: any) => {
|
|
setGroupField(group, 'guarantorName', client?.displayName);
|
|
setGroupField(group, 'telephone', client?.mobileNo);
|
|
this.clientsService.getClientAddressData(String(clientId), true).subscribe({
|
|
next: (addresses: any) => {
|
|
const address = Array.isArray(addresses) ? addresses[0] : addresses;
|
|
if (!address) {
|
|
return;
|
|
}
|
|
setGroupField(group, 'address1', address.addressLine1 || address.street);
|
|
setGroupField(group, 'address2', address.addressLine2);
|
|
setGroupField(group, 'city', address.city);
|
|
setGroupField(group, 'pinCode', address.postalCode);
|
|
},
|
|
error: () => undefined
|
|
});
|
|
},
|
|
// An id that matches no client just leaves the row for the officer to fill in.
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
addGuarantor(): void {
|
|
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. */
|
|
private static readonly CLIENT_DERIVED_FIELDS = [
|
|
'agentNameId',
|
|
'agentTelephone',
|
|
'agentAddress1',
|
|
'agentAddress2',
|
|
'agentCity',
|
|
'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
|
|
* empties them.
|
|
*/
|
|
loadClient(): void {
|
|
if (this.agentId) {
|
|
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.
|
|
this.clientSavingsAccounts = [];
|
|
this.savingsAccountTypes = [];
|
|
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 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));
|
|
},
|
|
error: () => {
|
|
this.clientLookupFailed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The client's own open savings accounts, to be picked from rather than typed.
|
|
*
|
|
* The account number used to be free text, and the live data showed the cost: agent AG0001 carried
|
|
* "1234567890", which matches no account at all - so every commission recorded against it had
|
|
* nowhere to go. Offering only the client's real accounts makes that impossible to key, and the
|
|
* account number then carries its correct product prefix by construction rather than by memory.
|
|
*/
|
|
private loadClientSavingsAccounts(clientId: string): void {
|
|
this.clientsService.getClientAccountData(clientId).subscribe({
|
|
next: (accounts: any) => {
|
|
this.clientSavingsAccounts = (accounts?.savingsAccounts || []).filter(
|
|
(account: any) =>
|
|
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
|
|
// single sum placed for a term while a recurring one takes only its own instalments -
|
|
// neither accepts the credit, so the posting would fail rather than the choice.
|
|
// A DDS scheme account is a savings account too and so passes the test above. It is
|
|
// dropped by the product the setting names - not by a product called "DDS", which is a
|
|
// name the bank may change - so the list offers only accounts the agent can be paid in.
|
|
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
|
|
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
|
|
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
|
|
);
|
|
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 = [];
|
|
this.savingsAccountTypes = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clears the account number when the type changes.
|
|
*
|
|
* Without this the previously picked account stays in the field while the list below it now shows
|
|
* a different type - the form would submit an account that contradicts its own type.
|
|
*/
|
|
onSavingsAccountTypePicked(): void {
|
|
this.agentForm.get('agentSavingAccountNo')?.setValue('');
|
|
}
|
|
|
|
private loadClientAddress(clientId: string): void {
|
|
this.clientsService.getClientAddressData(clientId, true).subscribe({
|
|
next: (addresses: any) => {
|
|
const address = Array.isArray(addresses) ? addresses[0] : addresses;
|
|
if (!address) {
|
|
return;
|
|
}
|
|
this.setField('agentAddress1', address.addressLine1 || address.street);
|
|
this.setField('agentAddress2', address.addressLine2);
|
|
this.setField('agentCity', address.city);
|
|
this.setField('agentPinCode', address.postalCode);
|
|
},
|
|
// A client with no address on file is normal — the officer just types it.
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
private clearClientDerivedFields(): void {
|
|
for (const controlName of CreateAgentComponent.CLIENT_DERIVED_FIELDS) {
|
|
this.agentForm.get(controlName)?.setValue('');
|
|
}
|
|
}
|
|
|
|
private setField(controlName: string, value: any): void {
|
|
if (value === null || value === undefined || value === '') {
|
|
return;
|
|
}
|
|
this.agentForm.get(controlName)?.setValue(String(value));
|
|
}
|
|
|
|
retrieve(): void {
|
|
const clientId = this.agentForm.get('clientId')?.value;
|
|
if (!clientId) {
|
|
return;
|
|
}
|
|
this.organizationService.getAgents().subscribe((agents: any[]) => {
|
|
const match = (agents || []).find((agent) => String(agent.clientId) === String(clientId));
|
|
if (match) {
|
|
this.patchFormFromAgent(match);
|
|
}
|
|
});
|
|
}
|
|
|
|
cancel(): void {
|
|
this.agentId = null;
|
|
this.loadedClientName = '';
|
|
this.clientLookupFailed = false;
|
|
this.agentForm.reset();
|
|
this.agentForm.patchValue({
|
|
agentCurrentBalance: 0,
|
|
agentOpeningBalance: 0
|
|
});
|
|
this.guarantors.clear();
|
|
}
|
|
|
|
exit(): void {
|
|
this.router.navigate(['/organization/agent-master']);
|
|
}
|
|
|
|
save(): void {
|
|
const raw = this.agentForm.getRawValue();
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const locale = this.settingsService.language.code;
|
|
|
|
const payload: any = {
|
|
clientId: Number(raw.clientId),
|
|
agentNameId: raw.agentNameId,
|
|
agentAddress1: raw.agentAddress1,
|
|
agentAddress2: raw.agentAddress2 || undefined,
|
|
agentCity: raw.agentCity,
|
|
agentPinCode: raw.agentPinCode,
|
|
agentTelephone: raw.agentTelephone || undefined,
|
|
officeId: Number(raw.officeId),
|
|
glAccountId: raw.glAccountId ? Number(raw.glAccountId) : undefined,
|
|
agentSavingAccountType: raw.agentSavingAccountType,
|
|
agentSavingAccountNo: raw.agentSavingAccountNo,
|
|
agentOpeningDate: this.dateUtils.formatDate(raw.agentOpeningDate, dateFormat),
|
|
agentBirthDate: this.dateUtils.formatDate(raw.agentBirthDate, dateFormat),
|
|
agentOpeningBalance: raw.agentOpeningBalance || 0,
|
|
dateFormat,
|
|
locale,
|
|
guarantors: (raw.guarantors || []).map((guarantor: any) => ({
|
|
clientId: Number(guarantor.clientId),
|
|
guarantorName: guarantor.guarantorName,
|
|
address1: guarantor.address1,
|
|
address2: guarantor.address2 || undefined,
|
|
city: guarantor.city,
|
|
pinCode: guarantor.pinCode,
|
|
telephone: guarantor.telephone || undefined
|
|
}))
|
|
};
|
|
|
|
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(done);
|
|
} else {
|
|
this.organizationService.createAgent(payload).subscribe(done);
|
|
}
|
|
}
|
|
|
|
private patchFormFromAgent(agent: any): void {
|
|
this.agentId = agent.id;
|
|
this.loadedClientName = agent.clientName || '';
|
|
this.clientLookupFailed = false;
|
|
this.agentForm.patchValue({
|
|
clientId: agent.clientId,
|
|
agentCode: agent.agentCode,
|
|
agentNameId: agent.agentNameId,
|
|
agentAddress1: agent.agentAddress1,
|
|
agentAddress2: agent.agentAddress2,
|
|
agentCity: agent.agentCity,
|
|
agentPinCode: agent.agentPinCode,
|
|
agentTelephone: agent.agentTelephone,
|
|
officeId: agent.officeId ? Number(agent.officeId) : '',
|
|
glAccountId: agent.glAccountId ? Number(agent.glAccountId) : '',
|
|
agentSavingAccountType: agent.agentSavingAccountType,
|
|
agentSavingAccountNo: agent.agentSavingAccountNo,
|
|
agentOpeningDate: agent.agentOpeningDate ? new Date(agent.agentOpeningDate) : null,
|
|
agentBirthDate: agent.agentBirthDate ? new Date(agent.agentBirthDate) : null,
|
|
agentCurrentBalance: agent.agentCurrentBalance,
|
|
agentOpeningBalance: agent.agentOpeningBalance
|
|
});
|
|
|
|
// The account list is needed for the dropdown to show the already-saved account as chosen
|
|
// rather than blank, so it is fetched on edit as well as on the client lookup.
|
|
if (agent.clientId) {
|
|
this.loadClientSavingsAccounts(String(agent.clientId));
|
|
}
|
|
|
|
this.guarantors.clear();
|
|
for (const guarantor of agent.guarantors || []) {
|
|
const group = this.buildGuarantorGroup();
|
|
group.patchValue({
|
|
clientId: guarantor.clientId,
|
|
guarantorName: guarantor.guarantorName,
|
|
address1: guarantor.address1,
|
|
address2: guarantor.address2,
|
|
city: guarantor.city,
|
|
pinCode: guarantor.pinCode,
|
|
telephone: guarantor.telephone
|
|
});
|
|
this.guarantors.push(group);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|