/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormControl, 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 { ClientsService } from 'app/clients/clients.service'; import { SavingsService } from 'app/savings/savings.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 { MatDialog } from '@angular/material/dialog'; import { MatTabsModule } from '@angular/material/tabs'; import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; import { businessToday } from 'app/core/utils/business-today'; import { CustomerSearchDialogComponent, CustomerSearchResult } from 'app/shared/customer-search-dialog/customer-search-dialog.component'; import { officesUnder } from '../offices-under'; /** The chart's account type for a liability: 1 asset, 2 liability, 3 equity, 4 income, 5 expense. */ const AGENT_GL_TYPE_LIABILITY = 2; /** 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, MatTabsModule, NgxMatSelectSearchModule ] }) 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 savingsService = inject(SavingsService); private accountingService = inject(AccountingService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private alertService = inject(AlertService); private authenticationService = inject(AuthenticationService); private dialog = inject(MatDialog); /** * 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; agentId: number | null = null; /** Shown under the Client ID field once a client has been resolved. */ loadedClientName = ''; clientLookupFailed = false; /** * Set when what was typed in the Client ID field is not an ID - a name, or a mobile number - so the * field can say to search for it rather than calling it a client that does not exist. */ clientNeedsSearch = false; /** * The tabs the form is set out over - Agent Details, Address & Contact, Branch & Accounts, * Guarantors, Balances - and the one showing. Every field is part of the one form whichever tab * it is on, so moving between tabs loses and changes nothing. */ selectedTabIndex = 0; /** Balances is the fifth tab, and only an agent already on the books has one. */ get tabCount(): number { return this.agentId ? 5 : 4; } /** The controls each tab holds, in tab order, so a tab can say it still needs attention. */ private static readonly TAB_CONTROLS: string[][] = [ [ 'officeId', 'clientId', 'agentCode', 'agentNameId', 'agentOpeningDate', 'agentBirthDate' ], [ 'agentAddress1', 'agentAddress2', 'agentCity', 'agentPinCode', 'agentTelephone' ], [ 'glAccountId', 'agentSavingAccountType', 'agentSavingAccountNo' ], [], [ 'agentCurrentBalance', 'agentOpeningBalance' ] ]; /** * Whether a tab holds something to put right: a field that is invalid and has been visited, or - on * the first tab - a client who cannot be made an agent. Display only; Save is decided as before. */ tabHasError(index: number): boolean { const visited = (control: AbstractControl | null) => !!control && control.invalid && (control.touched || control.dirty); if (index === 3) { return this.guarantors.controls.some((row) => { const group = row as UntypedFormGroup; return Object.values(group.controls).some((control) => visited(control)); }); } const controls = CreateAgentComponent.TAB_CONTROLS[index] ?? []; const flagged = controls.some((name) => visited(this.agentForm.get(name))); if (index === 0) { return ( flagged || this.clientNeedsSearch || !!this.clientAlreadyAgent || !!this.clientAgeProblem || this.clientOtherBranch ); } return flagged; } /** An Indian mobile number is ten digits; a client ID is never that long. */ private static readonly MOBILE_NUMBER_LENGTH = 10; /** * The code the next agent is expected to get, shown in place of an Agent Code field - the code is * assigned by the server when the agent is saved, never keyed. It is worked out the way the server * does it: "AG" and one more than the highest number among every agent on the books, closed ones * included. An estimate only - another agent saved first, or one waiting for approval, can take it. */ expectedAgentCode = ''; private static nextAgentCode(agents: any[]): string { let highest = 0; for (const agent of agents) { const match = /^AG(\d+)$/.exec(String(agent?.agentCode ?? '')); if (match) { highest = Math.max(highest, Number(match[1])); } } return `AG${String(highest + 1).padStart(4, '0')}`; } /** * 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[] = []; /** * What is typed in the GL Account list's search box. The list narrows to the accounts whose code or * name contains it, as the account pickers on the product screens do. Only what is shown changes - * the field still holds the account's id. */ glAccountSearch = new UntypedFormControl(''); get filteredGlAccounts(): any[] { const search = String(this.glAccountSearch.value ?? '') .trim() .toLowerCase(); if (!search) { return this.glAccounts; } return this.glAccounts.filter( (account: any) => String(account.glCode ?? '') .toLowerCase() .includes(search) || String(account.name ?? '') .toLowerCase() .includes(search) ); } /** * 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. // Up to nine digits - a mobile number is ten, so one typed in full is not mistaken for an id. [ Validators.required, Validators.pattern(/^\d{1,9}$/) ] ], 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 ], // A new agent is opened on the bank's business date, not the computer's clock; it can still be // changed, and an agent being edited keeps the date it was opened on. agentOpeningDate: [ businessToday(), 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 client already keyed is checked against a different branch when that is chosen. this.agentForm .get('officeId') ?.valueChanges.pipe(distinctUntilChanged()) .subscribe(() => { 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; } } this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []); }, 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.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. // // Liability accounts only (type 2). The cashier credits this account with the cash an agent // hands over and Apply DDS Receipt debits it as the money is placed on the customers, so what // stands on it is held for others - a liability. An asset, income or expense account would // book that money as the bank's own. this.glAccounts = rows.filter( (a: any) => a.usage?.id === 1 && !a.disabled && a.type?.id === AGENT_GL_TYPE_LIABILITY ); }, 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 or a mobile number is something to search for, not a client yet. Nothing is looked // up as the officer types - the search button does that - so the fields stay until one is // picked, and no client is asked for on a guess. this.clientNeedsSearch = this.needsSearch(typed); if (this.clientNeedsSearch) { return; } 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(); } }); } /** * Whether what was typed is something to search for rather than a client ID: letters, which make it * a name, or digits as long as a mobile number. */ private needsSearch(typed: string): boolean { if (!typed) { return false; } return !/^\d+$/.test(typed) || typed.length >= CreateAgentComponent.MOBILE_NUMBER_LENGTH; } /** * Opens Search Customer - the same dialog, and the same customer search API, the cash screens use - * to find the agent's client by ID, name or mobile number, and puts the one picked into the field, * which then fills the rest as an ID keyed in would. */ openClientSearch(): void { if (this.agentId) { return; } this.openCustomerSearch((result) => this.agentForm.get('clientId')?.setValue(String(result.clientId))); } /** The same search for a guarantor row, which fills that row from the client picked. */ openGuarantorSearch(group: AbstractControl): void { this.openCustomerSearch((result) => group.get('clientId')?.setValue(String(result.clientId)), group); } private openCustomerSearch(picked: (result: CustomerSearchResult) => void, row?: AbstractControl): void { const typed = String((row ?? this.agentForm).get('clientId')?.value ?? '').trim(); this.dialog .open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', // An agent or guarantor is a customer first - they need not hold an account to be picked, and // are listed once however many accounts they have. What was already typed is searched for. data: { requireAccount: false, uniqueCustomers: true, prefill: typed } }) .afterClosed() .subscribe((result: CustomerSearchResult | undefined) => { if (result?.clientId) { picked(result); } }); } get guarantors(): UntypedFormArray { return this.agentForm.get('guarantors') as UntypedFormArray; } private buildGuarantorGroup(): UntypedFormGroup { const group = this.formBuilder.group({ clientId: [ '', [ Validators.required, Validators.pattern(/^\d{1,9}$/), 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. // A keyed id fills the row; a name or mobile number is found with the row's search button. group .get('clientId') ?.valueChanges.pipe(debounceTime(500), distinctUntilChanged()) .subscribe((value) => { const typed = String(value ?? '').trim(); if (this.needsSearch(typed)) { return; } this.loadGuarantorClient(group, typed); // 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; } /** 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); } 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' ]; /** * 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.savingsInfo = null; this.savingsInfoFailed = false; 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 customer's scheme account would pass this too; the server refuses one on save. account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE && (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; } } // An agent being edited already has an account chosen: show how it stands as soon as the // client's accounts are known. this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value); }, 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(''); this.savingsInfo = null; } /** * How the account commission is paid into stands: its status, balance, what is on hold, and what * is left to use. Read from the account itself, the same figures the cashier's screen shows for it, * so the officer sees whether it can take the credit before saving rather than when a run fails. * Display only - nothing here is saved or checked. */ savingsInfo: { status: string; statusClass: 'ok' | 'wait' | 'off'; symbol: string; balance: number; hold: number; available: number; } | null = null; savingsInfoLoading = false; savingsInfoFailed = false; onSavingsAccountPicked(): void { this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value); } private showSavingsAccountInfo(accountNo: string | null | undefined): void { this.savingsInfo = null; this.savingsInfoFailed = false; this.savingsInfoLoading = false; const match = accountNo ? this.clientSavingsAccounts.find((account: any) => account.accountNo === accountNo) : null; if (!match) { return; } this.savingsInfoLoading = true; this.savingsService.getSavingsAccountData(String(match.id)).subscribe({ next: (account: any) => { // The account may have been swapped for another while this was on its way. if (this.agentForm.get('agentSavingAccountNo')?.value !== accountNo) { return; } const status = account?.status ?? {}; this.savingsInfo = { status: status.value || '—', statusClass: status.active ? 'ok' : status.submittedAndPendingApproval || status.approved ? 'wait' : 'off', symbol: account?.currency?.displaySymbol ?? '₹', balance: Number(account?.summary?.accountBalance ?? 0), // Savings carries a lien or hold as a figure on the account itself, and its available // balance is already worked out with every hold taken off - as on the cashier's screen. hold: Number(account?.savingsAmountOnHold ?? 0), available: Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0) }; this.savingsInfoLoading = false; }, error: () => { this.savingsInfoLoading = false; this.savingsInfoFailed = true; } }); } 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, agentOpeningDate: businessToday() }); this.guarantors.clear(); this.savingsInfo = null; this.savingsInfoFailed = false; // Back to the first tab, where a fresh entry begins. this.selectedTabIndex = 0; } 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; } /** 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; }