Files
cbsnexora/src/app/organization/agent-master/create-agent/create-agent.component.ts
T
niteshandClaude Opus 5.5 53bb97d6ed feat(dds): DDS product creation in tabs with an Accounting tab
- DDS Product Creation split into Details / Interest & Maturity /
  Premature Closure / Accounting tabs, like the savings product
- Accounting tab picks Customer Deposits (liability), Interest Expense
  and Agent Commission Expense (expense) GLs; product view shows them
- Approval review shows the product's GLs, old -> new on edits
- Agent master no longer reads the removed dds-deposit-product setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:43:51 +05:30

1007 lines
37 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 { 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<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;
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<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[] = [];
/**
* 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;
}