/** * 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; /** * 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 = {}; /** 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 = {}; /** 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; }