1945 lines
70 KiB
TypeScript
1945 lines
70 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,
|
|
UntypedFormGroup,
|
|
UntypedFormArray,
|
|
Validators,
|
|
ValidatorFn,
|
|
ValidationErrors,
|
|
AbstractControl
|
|
} from '@angular/forms';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { MatTabsModule } from '@angular/material/tabs';
|
|
import { MatCheckbox } from '@angular/material/checkbox';
|
|
import { MatRadioModule } from '@angular/material/radio';
|
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
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 { ProductsService } from 'app/products/products.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 { searchClientsAnyCase } from '../client-search';
|
|
|
|
/** Custom Components */
|
|
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
|
|
|
|
/** Custom Directives */
|
|
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
|
|
|
|
/**
|
|
* Party Master Entry form.
|
|
*
|
|
* The customer's DDS account - opened under a scheme (Sub Type, chosen from the DDS Account Type
|
|
* master) and assigned to the agent who will collect for it daily. Carries its own KYC, scheme
|
|
* terms, two-stage overdue flags and a nominee list. Modelled on the legacy Party Master Entry
|
|
* screen, field for field.
|
|
*
|
|
* The legacy screen filled the identity block from the Client ID and showed the agent's name beside
|
|
* the agent field; both are reproduced here, and the same lookup fills the guardian's name for a
|
|
* minor's account. That field read "Sub Agent" in the legacy system, which had a tier this one does
|
|
* not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or
|
|
* written by collections and the batch job - none of them are typed here.
|
|
*
|
|
* "Close Account" (command=close) permanently closes the Party once its balance is zero - the
|
|
* backend rejects the command otherwise. A closed Party can no longer be edited or receive new
|
|
* Party Tran collections.
|
|
*
|
|
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
|
|
* the same client (resolved by account number, since that's what an officer has to hand) -
|
|
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
|
|
* leave it". There is no unlink once linked.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-create-party',
|
|
templateUrl: './create-party.component.html',
|
|
styleUrls: ['./create-party.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatTabsModule,
|
|
MatCheckbox,
|
|
MatRadioModule,
|
|
MatAutocompleteModule,
|
|
// Holds the identifier fields to the shape their kind takes, at the keystroke. Imported here
|
|
// because a standalone component silently ignores a directive it has not imported - the
|
|
// attribute stays on the element and simply does nothing.
|
|
RestrictInputDirective
|
|
]
|
|
})
|
|
export class CreatePartyComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private router = inject(Router);
|
|
private route = inject(ActivatedRoute);
|
|
private organizationService = inject(OrganizationService);
|
|
private productsService = inject(ProductsService);
|
|
private alertService = inject(AlertService);
|
|
private clientsService = inject(ClientsService);
|
|
private settingsService = inject(SettingsService);
|
|
private dateUtils = inject(Dates);
|
|
private dialog = inject(MatDialog);
|
|
|
|
/*
|
|
* What each identifier has to look like - the same shapes the server holds them to.
|
|
*
|
|
* Checked here as well so the clerk is told while the customer is still at the counter, rather
|
|
* than after a save that comes back rejected. The server checks them again regardless: a screen
|
|
* is a convenience, not a guarantee.
|
|
*/
|
|
private static readonly MOBILE_RE = /^[6-9][0-9]{9}$/;
|
|
private static readonly TELEPHONE_RE = /^[0-9+()\-\s]{6,20}$/;
|
|
private static readonly PINCODE_RE = /^[1-9][0-9]{5}$/;
|
|
/** Twelve digits, or the masked form CKYC hands back. */
|
|
private static readonly AADHAAR_RE = /^([0-9]{12}|[Xx]{8}[0-9]{4})$/;
|
|
private static readonly PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
|
private static readonly CKYC_RE = /^[0-9]{14}$/;
|
|
|
|
partyId: number | null = null;
|
|
|
|
/**
|
|
* The earliest opening date the picker will offer on a new account - today, so the past is greyed
|
|
* out. The server refuses a past date on creation too; this only stops the clerk reaching for one.
|
|
*
|
|
* Null while editing. An account already on the books opened in the past by definition, so a floor
|
|
* of today would put every existing party into a state its own form would not accept.
|
|
*/
|
|
get minOpeningDate(): Date | null {
|
|
return this.partyId === null ? new Date() : null;
|
|
}
|
|
|
|
selectedTabIndex = 0;
|
|
partyStatus = 'ACTIVE';
|
|
linkedSavingsAccountId: number | null = null;
|
|
|
|
/**
|
|
* The customer's real deposit account, as the bank's own books hold it. Read from the account
|
|
* itself rather than copied onto the party, so what shows here cannot drift from what is true.
|
|
*/
|
|
savingsAccountNo: string | null = null;
|
|
savingsProductName: string | null = null;
|
|
savingsAccountStatus: string | null = null;
|
|
savingsAccountBalance: number | null = null;
|
|
|
|
/** The DDS account's own number, scheme and balance - the party is the account. */
|
|
accountNo: string | null = null;
|
|
accountSchemeName: string | null = null;
|
|
accountBalance: number | null = null;
|
|
|
|
/**
|
|
* A minor account holder who has come of age. The guardian's authority ended on that day, so the
|
|
* branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field.
|
|
*/
|
|
majorOn: string | null = null;
|
|
minorTurnedMajor = false;
|
|
|
|
/** Shown beside the id fields once the record behind them has been resolved. */
|
|
loadedClientName = '';
|
|
clientLookupFailed = false;
|
|
loadedAgentName = '';
|
|
agentLookupFailed = false;
|
|
loadedGuardianName = '';
|
|
|
|
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */
|
|
clientOptions: any[] = [];
|
|
|
|
/**
|
|
* The first page of clients, shown the moment the field is focused so the officer can pick from a
|
|
* list instead of having to know something to type. Fetched once and kept.
|
|
*
|
|
* Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank
|
|
* with far more later, and the field has to stay usable in both. Typing still searches every
|
|
* client, not just these - see searchClients.
|
|
*/
|
|
private initialClients: any[] = [];
|
|
private static readonly CLIENT_LIST_SIZE = 50;
|
|
|
|
/** How many clients exist in total, to say so when the list below is only part of them. */
|
|
clientTotal = 0;
|
|
|
|
/** True while the options being shown are the opening list rather than search results. */
|
|
showingClientList = false;
|
|
|
|
get clientListIsPartial(): boolean {
|
|
return this.showingClientList && this.clientTotal > this.clientOptions.length;
|
|
}
|
|
guardianOptions: any[] = [];
|
|
/** The account types matching what has been typed into Sub Type. */
|
|
subTypeOptions: any[] = [];
|
|
|
|
/**
|
|
* The agents on offer. Every agent is fetched once and filtered here rather than asked of the
|
|
* server per keystroke - there is a handful of them, not a branch's worth of customers.
|
|
*/
|
|
agentOptions: any[] = [];
|
|
private allAgents: any[] = [];
|
|
|
|
get isClosed(): boolean {
|
|
return this.partyStatus === 'CLOSED';
|
|
}
|
|
|
|
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
|
|
subTypes: any[] = [];
|
|
|
|
operations = [
|
|
'Self',
|
|
'Guardian',
|
|
'Joint'
|
|
];
|
|
occupations = [
|
|
'Business',
|
|
'Service',
|
|
'Agriculture',
|
|
'Homemaker',
|
|
'Student',
|
|
'Retired',
|
|
'Other'
|
|
];
|
|
riskLevels = [
|
|
'Low',
|
|
'Medium',
|
|
'High'
|
|
];
|
|
adultMinorOptions = [
|
|
'Adult',
|
|
'Minor'
|
|
];
|
|
withdrawTypes = [
|
|
'Cash',
|
|
'Transfer',
|
|
'Clearing'
|
|
];
|
|
freezeCodes = [
|
|
'Normal',
|
|
'Debit Freeze',
|
|
'Credit Freeze',
|
|
'Total Freeze'
|
|
];
|
|
advCodes = [
|
|
'None',
|
|
'Transfer To Saving',
|
|
'Renew'
|
|
];
|
|
|
|
/** The bank's own branches - where maturity proceeds can be credited. */
|
|
/**
|
|
* Why a deposit gets held, offered as a list rather than typed.
|
|
*
|
|
* Free text meant the same reason arrived four ways - "Loan 1234", "loan no 1234", "LOAN#1234" -
|
|
* and anything counting them read four different reasons. Picked from a list they group properly,
|
|
* and the clerk types only where the list genuinely does not cover it.
|
|
*/
|
|
lienReasons = [
|
|
'Loan security',
|
|
'Court order',
|
|
'Tax attachment',
|
|
'Deceased — claim pending',
|
|
'Under investigation',
|
|
'Other'
|
|
];
|
|
|
|
offices: any[] = [];
|
|
officesError = '';
|
|
|
|
/**
|
|
* The customer's own savings accounts, for Credit To A/C.
|
|
*
|
|
* Picked rather than typed. The whole matured amount lands here years after somebody fills this
|
|
* in, and the live data showed what free text costs: one party carried "1234567890111", which is
|
|
* no account at all. Only savings accounts are offered - a fixed or recurring deposit cannot take
|
|
* the credit - and only this customer's own, which is what a maturity instruction means in any
|
|
* core system. The backend refuses anything else, whatever the screen sends.
|
|
*/
|
|
creditToAccounts: any[] = [];
|
|
|
|
/** The Adv Code that makes the account say where its maturity proceeds should land. */
|
|
private static readonly TRANSFER_TO_SAVING = 'Transfer To Saving';
|
|
|
|
/** Filled from the Client ID, as on the legacy screen. */
|
|
private static readonly CLIENT_DERIVED_FIELDS = [
|
|
'partyName',
|
|
'partyName1',
|
|
'address1',
|
|
'address2',
|
|
'address3',
|
|
'city',
|
|
'pincode',
|
|
'gender',
|
|
'birthDate',
|
|
'telephone',
|
|
'mobileNoSms',
|
|
'occupation',
|
|
'risk',
|
|
'aadharNo',
|
|
'panCardNo',
|
|
'ckycId',
|
|
'relation',
|
|
'minorDate',
|
|
'guardianClientId'
|
|
];
|
|
|
|
/** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */
|
|
private filledFromClient = new Set<string>();
|
|
|
|
private static readonly MINOR_FIELDS = [
|
|
'minorDate',
|
|
'guardianClientId',
|
|
'minAccountOpt',
|
|
'relation'
|
|
];
|
|
|
|
private static readonly CREDIT_TO_FIELDS = [
|
|
'creditToBranch',
|
|
'creditToAccountType',
|
|
'creditToAccount'
|
|
];
|
|
|
|
partyForm: UntypedFormGroup = this.formBuilder.group({
|
|
clientId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
subType: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
agentId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
// The branch this account belongs to. Its own, not the collecting agent's - an agent of one
|
|
// branch may hold accounts for customers of another, so it is asked for rather than inherited.
|
|
officeId: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
savingsAccountNo: [''],
|
|
lastAccount: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
partyCode: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
nominal: [
|
|
'No',
|
|
Validators.required
|
|
],
|
|
aadharNo: [
|
|
'',
|
|
Validators.pattern(CreatePartyComponent.AADHAAR_RE)
|
|
],
|
|
partyName: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
partyName1: [''],
|
|
address1: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
address2: [''],
|
|
address3: [''],
|
|
city: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
pincode: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(CreatePartyComponent.PINCODE_RE)
|
|
]
|
|
],
|
|
gender: [''],
|
|
panCardNo: [
|
|
'',
|
|
Validators.pattern(CreatePartyComponent.PAN_RE)
|
|
],
|
|
telephone: [
|
|
'',
|
|
Validators.pattern(CreatePartyComponent.TELEPHONE_RE)
|
|
],
|
|
mobileNoSms: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(CreatePartyComponent.MOBILE_RE)
|
|
]
|
|
],
|
|
operation: [
|
|
'Self',
|
|
Validators.required
|
|
],
|
|
birthDate: [''],
|
|
occupation: [''],
|
|
risk: [''],
|
|
ckycId: [
|
|
'',
|
|
Validators.pattern(CreatePartyComponent.CKYC_RE)
|
|
],
|
|
adultMinor: [
|
|
'Adult',
|
|
Validators.required
|
|
],
|
|
// Enabled only for a minor's account - see applyAdultMinorRules().
|
|
minorDate: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
guardianClientId: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
minAccountOpt: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
relation: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
openingDate: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
month: [''],
|
|
// Worked out from the opening date and the tenure - never typed.
|
|
maturityDate: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
reopenDate: [''],
|
|
depositAmount: [0],
|
|
dailyDepositAmount: [
|
|
0,
|
|
Validators.required
|
|
],
|
|
days: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
ledgerNo: [''],
|
|
maturityAmount: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
interestRate: [0],
|
|
withdrawType: [''],
|
|
// The overdue flags, balances and interest figures below are written by collections and the
|
|
// DDS batch job. They are shown so an officer can see where the account stands, never typed.
|
|
od1Flag: [
|
|
{
|
|
value: false,
|
|
disabled: true
|
|
}
|
|
],
|
|
od2Flag: [
|
|
{
|
|
value: false,
|
|
disabled: true
|
|
}
|
|
],
|
|
lien: ['No'],
|
|
// Asked for only while the lien is set - the rule is applied from the flag rather
|
|
// than stated here, so switching it off does not leave a required field behind.
|
|
lienReason: [''],
|
|
// What the clerk types when the list does not cover it. Kept apart from lienReason so the list
|
|
// stays a list - the one value the server stores is worked out at save.
|
|
lienReasonOther: [''],
|
|
payDate: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
paymentAmount: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
currentBalance: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
openingBalance: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
isSmsActive: [true],
|
|
isEmailActive: [false],
|
|
interestCalc: [
|
|
{
|
|
value: 0,
|
|
disabled: true
|
|
}
|
|
],
|
|
interestDate: [
|
|
{
|
|
value: '',
|
|
disabled: true
|
|
}
|
|
],
|
|
spDescription: [''],
|
|
freezeCode: ['Normal'],
|
|
advCode: ['None'],
|
|
// Mandatory only when Adv Code is Transfer To Saving - see applyAdvCodeRules().
|
|
creditToBranch: [''],
|
|
creditToAccountType: [''],
|
|
creditToAccount: [''],
|
|
nomineeApplicable: ['No'],
|
|
nomineeSplitMode: ['Percentage'],
|
|
nominees: this.formBuilder.array([])
|
|
});
|
|
|
|
ngOnInit(): void {
|
|
this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => {
|
|
// A retired scheme takes no new accounts, but one already on it must still show when edited.
|
|
this.subTypes = (accountTypes || []).filter((accountType) => accountType.active);
|
|
this.subTypeOptions = this.subTypes;
|
|
});
|
|
|
|
// Branches are picked, never typed: a free-text branch cannot be matched back to a real office
|
|
// when the proceeds are actually moved. If the list cannot be fetched the reason is shown on the
|
|
// field - an empty dropdown with no explanation looks like the bank has no branches.
|
|
this.organizationService.getOffices().subscribe({
|
|
next: (offices: any) => {
|
|
this.offices = Array.isArray(offices) ? offices : offices?.pageItems || [];
|
|
this.officesError = this.offices.length === 0 ? 'No branches on file' : '';
|
|
},
|
|
error: (error: any) => {
|
|
this.officesError =
|
|
error?.error?.defaultUserMessage ||
|
|
error?.error?.developerMessage ||
|
|
`Could not load branches (${error?.status || 'no response'})`;
|
|
}
|
|
});
|
|
|
|
const routeId = this.route.snapshot.paramMap.get('partyId');
|
|
if (routeId) {
|
|
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
|
|
}
|
|
|
|
this.partyForm.get('days')?.addValidators(this.withinTerm());
|
|
|
|
this.wireClientLookup();
|
|
this.wireSubTypeSearch();
|
|
this.wireAgentLookup();
|
|
this.wireGuardianLookup();
|
|
this.wireConditionalRules();
|
|
this.wireMaturityCalculation();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- lookups
|
|
|
|
/**
|
|
* Fills the identity block from the customer, the way the legacy screen did - except that the
|
|
* customer can be found by name as well as by id, since an officer at the counter is told a name.
|
|
* The control holds the picked client object, or whatever has been typed so far.
|
|
*/
|
|
private wireClientLookup(): void {
|
|
this.partyForm
|
|
.get('clientId')
|
|
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
|
.subscribe((value) => {
|
|
// Always replace: changing the customer must not leave the previous one's details behind.
|
|
for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) {
|
|
this.partyForm.get(controlName)?.setValue('');
|
|
}
|
|
this.filledFromClient.clear();
|
|
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
|
|
this.loadedClientName = '';
|
|
this.clientLookupFailed = false;
|
|
|
|
// Picked from the list.
|
|
if (value && typeof value === 'object') {
|
|
this.clientOptions = [];
|
|
this.fillFromClient(value.id, value.displayName);
|
|
return;
|
|
}
|
|
|
|
const typed = String(value ?? '').trim();
|
|
if (!typed) {
|
|
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
|
|
this.showClientList();
|
|
return;
|
|
}
|
|
|
|
// A bare number is an id typed straight in; anything else is a name to search for. A number
|
|
// can be both, so both are tried and whichever matches wins.
|
|
if (/^\d+$/.test(typed)) {
|
|
this.fillFromClient(typed, '');
|
|
}
|
|
this.searchClients(typed);
|
|
});
|
|
}
|
|
|
|
private searchClients(term: string): void {
|
|
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
|
|
this.showingClientList = false;
|
|
this.clientOptions = found.slice(0, 25);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Opens the customer list when the field is focused.
|
|
*
|
|
* Without this the officer has to know something before anything appears, which is backwards: the
|
|
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
|
|
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
|
|
*/
|
|
onClientFocus(): void {
|
|
if (this.partyForm.get('clientId')?.value) {
|
|
return;
|
|
}
|
|
this.showClientList();
|
|
}
|
|
|
|
/** Shows the opening list, fetching it the first time it is asked for. */
|
|
private showClientList(): void {
|
|
if (this.initialClients.length > 0) {
|
|
this.showingClientList = true;
|
|
this.clientOptions = this.initialClients;
|
|
return;
|
|
}
|
|
|
|
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({
|
|
next: (response: any) => {
|
|
const found = response?.pageItems || response || [];
|
|
this.initialClients = Array.isArray(found) ? found : [];
|
|
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
|
|
this.showingClientList = true;
|
|
this.clientOptions = this.initialClients;
|
|
},
|
|
// A failed list is not a failed screen - the officer can still type a name or an id.
|
|
error: () => (this.clientOptions = [])
|
|
});
|
|
}
|
|
|
|
private fillFromClient(clientId: string | number, knownName: string): void {
|
|
this.clientsService.getClientData(String(clientId), true).subscribe({
|
|
next: (client: any) => {
|
|
this.loadedClientName = client?.displayName || knownName || '';
|
|
this.clientLookupFailed = false;
|
|
this.fillFromClientRecord('partyName', client?.displayName);
|
|
this.fillFromClientRecord('mobileNoSms', client?.mobileNo);
|
|
this.fillFromClientRecord('telephone', client?.alternateMobileNo);
|
|
// Alerts go where the client can actually be reached: SMS with a mobile on record, email with
|
|
// an email address on record.
|
|
this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo);
|
|
this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress);
|
|
// The client record says FEMALE; this form's list says Female.
|
|
this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender));
|
|
const dateOfBirth = this.toDate(client?.dateOfBirth);
|
|
if (dateOfBirth) {
|
|
this.partyForm.get('birthDate')?.setValue(dateOfBirth);
|
|
this.filledFromClient.add('birthDate');
|
|
}
|
|
// The client's own occupation, offered even when this form's short list does not carry it.
|
|
const occupation = client?.occupation?.name || client?.occupationCategory?.name;
|
|
if (occupation) {
|
|
if (!this.occupations.includes(occupation)) {
|
|
this.occupations = [
|
|
...this.occupations,
|
|
occupation
|
|
];
|
|
}
|
|
this.fillFromClientRecord('occupation', occupation);
|
|
}
|
|
// A minor on the client record is a minor account here, with the same guardian.
|
|
this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult');
|
|
// Adult or minor is the client record's, checked when the client was opened - not changed here.
|
|
this.partyForm.get('adultMinor')?.disable({ emitEvent: false });
|
|
this.filledFromClient.add('adultMinor');
|
|
if (client?.isMinor) {
|
|
if (dateOfBirth) {
|
|
// The day they come of age - eighteen is majority in India.
|
|
const majority = new Date(dateOfBirth);
|
|
majority.setFullYear(majority.getFullYear() + 18);
|
|
this.partyForm.get('minorDate')?.setValue(majority);
|
|
this.filledFromClient.add('minorDate');
|
|
}
|
|
if (client?.guardianClientId) {
|
|
this.partyForm
|
|
.get('guardianClientId')
|
|
?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' });
|
|
this.filledFromClient.add('guardianClientId');
|
|
}
|
|
this.fillFromClientRecord('relation', titleCase(client?.guardianRelation));
|
|
}
|
|
this.loadClientRisk(String(clientId));
|
|
// Nominees are not brought across: the officer records them for this account.
|
|
// Proceeds usually go back to an account at the customer's own branch, so that is offered
|
|
// first - any branch can still be picked, since the account may well be at another.
|
|
const creditToBranch = this.partyForm.get('creditToBranch');
|
|
if (client?.officeName && !creditToBranch?.value) {
|
|
creditToBranch?.setValue(client.officeName, { emitEvent: false });
|
|
}
|
|
|
|
this.loadClientAddress(String(clientId));
|
|
this.loadClientIdentifiers(String(clientId));
|
|
this.loadCreditToAccounts(String(clientId));
|
|
},
|
|
error: () => {
|
|
this.clientLookupFailed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The customer's own accounts a maturity may be credited to.
|
|
*
|
|
* Worked out by the server, which applies the same conditions the payout enforces years later: an
|
|
* active savings account of theirs, not a fixed or recurring deposit, and not a DDS scheme account
|
|
* - those are collected on daily and pay out at their own maturity, so naming one here would send
|
|
* this maturity into another collection account instead of back to the customer.
|
|
*
|
|
* Deciding it here instead let the list and the refusal drift apart, and the accounts on offer
|
|
* came to include a scheme account of a party that had already closed.
|
|
*/
|
|
private loadCreditToAccounts(clientId: string): void {
|
|
this.organizationService.getCreditableAccounts(clientId).subscribe({
|
|
next: (accounts: any) => (this.creditToAccounts = accounts || []),
|
|
error: () => (this.creditToAccounts = [])
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fills the account type from the account chosen. An account already is of a type, and asking for
|
|
* both lets the two disagree - the account is the half that is actually true.
|
|
*/
|
|
onCreditToAccountPicked(accountNo: string): void {
|
|
const account = this.creditToAccounts.find((each: any) => each.accountNo === accountNo);
|
|
if (account?.productName) {
|
|
this.partyForm.get('creditToAccountType')?.setValue(account.productName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aadhar, PAN and CKYC are not plain client fields - they are KYC identifiers filed against the
|
|
* client, each tagged with a document type. They are matched by that type's name so the screen
|
|
* shows the same numbers the client record already holds, rather than asking for them twice.
|
|
*/
|
|
private loadClientIdentifiers(clientId: string): void {
|
|
this.clientsService.getClientIdentifiers(clientId).subscribe({
|
|
next: (identifiers: any) => {
|
|
for (const identifier of Array.isArray(identifiers) ? identifiers : []) {
|
|
const words = this.wordsOf(identifier?.documentType?.name);
|
|
const documentKey = identifier?.documentKey;
|
|
if (!documentKey) {
|
|
continue;
|
|
}
|
|
if (words.includes('aadhaar') || words.includes('aadhar')) {
|
|
this.fillFromClientRecord('aadharNo', documentKey);
|
|
} else if (words.includes('pan')) {
|
|
this.fillFromClientRecord('panCardNo', documentKey);
|
|
} else if (words.includes('ckyc')) {
|
|
this.fillFromClientRecord('ckycId', documentKey);
|
|
}
|
|
}
|
|
},
|
|
// A client with no identifiers on file is normal - the officer just types them.
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
/** What the Client ID box shows for a picked client, rather than "[object Object]". */
|
|
displayClient = (value: any): string => {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value);
|
|
};
|
|
|
|
/**
|
|
* Takes a value from the client record, which was checked when the client was opened, and locks
|
|
* the field so it is not typed - and checked - a second time. A value the client does not have is
|
|
* left for the officer to enter.
|
|
*/
|
|
private fillFromClientRecord(controlName: string, value: any): void {
|
|
if (value === null || value === undefined || String(value).trim() === '') {
|
|
return;
|
|
}
|
|
this.setField(controlName, value);
|
|
this.filledFromClient.add(controlName);
|
|
}
|
|
|
|
/** Whether a field holds a value taken from the client record, and so is shown read-only. */
|
|
isFromClient(controlName: string): boolean {
|
|
return this.filledFromClient.has(controlName);
|
|
}
|
|
|
|
/** The client's risk category, from their KYC record. */
|
|
private loadClientRisk(clientId: string): void {
|
|
this.clientsService.getClientKyc(clientId, true).subscribe({
|
|
next: (kyc: any) => {
|
|
// kycRiskCategory.medium -> Medium
|
|
const code = String(kyc?.riskCategoryCode || '')
|
|
.split('.')
|
|
.pop();
|
|
this.fillFromClientRecord('risk', titleCase(code));
|
|
},
|
|
// A client without a KYC record just leaves the risk to be chosen.
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
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.fillFromClientRecord('address1', address.addressLine1 || address.street);
|
|
this.fillFromClientRecord('address2', address.addressLine2);
|
|
this.fillFromClientRecord('address3', address.addressLine3);
|
|
this.fillFromClientRecord('city', address.city);
|
|
this.fillFromClientRecord('pincode', address.postalCode);
|
|
},
|
|
// A client with no address on file is normal - the officer just types it.
|
|
error: () => undefined
|
|
});
|
|
}
|
|
|
|
/** Shows the collecting agent's name beside the id, as the legacy screen did in red. */
|
|
private wireAgentLookup(): void {
|
|
this.partyForm
|
|
.get('agentId')
|
|
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
|
.subscribe((value) => {
|
|
this.loadedAgentName = '';
|
|
this.agentLookupFailed = false;
|
|
|
|
// Picked from the list - the agent is already in hand, nothing to look up.
|
|
if (value && typeof value === 'object') {
|
|
this.agentOptions = [];
|
|
this.loadedAgentName = value.agentNameId || value.clientName || '';
|
|
this.agentLookupFailed = !value.active;
|
|
return;
|
|
}
|
|
|
|
const typed = String(value ?? '').trim();
|
|
if (!typed) {
|
|
this.showAgentList();
|
|
return;
|
|
}
|
|
|
|
this.filterAgents(typed);
|
|
|
|
// A bare number is an id typed straight in, the way this field always worked.
|
|
if (!/^\d+$/.test(typed)) {
|
|
return;
|
|
}
|
|
this.organizationService.getAgent(typed).subscribe({
|
|
next: (agent: any) => {
|
|
this.loadedAgentName = agent?.agentNameId || agent?.clientName || '';
|
|
this.agentLookupFailed = !agent?.active;
|
|
},
|
|
error: () => {
|
|
this.agentLookupFailed = true;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Opens the agent list on focus, so the field can be answered by picking rather than by knowing
|
|
* an id. A chosen agent is left alone - reopening over it only invites a misclick.
|
|
*/
|
|
onAgentFocus(): void {
|
|
if (this.partyForm.get('agentId')?.value) {
|
|
return;
|
|
}
|
|
this.showAgentList();
|
|
}
|
|
|
|
private showAgentList(): void {
|
|
if (this.allAgents.length > 0) {
|
|
this.agentOptions = this.allAgents;
|
|
return;
|
|
}
|
|
this.organizationService.getAgents().subscribe({
|
|
next: (agents: any[]) => {
|
|
this.allAgents = agents || [];
|
|
this.agentOptions = this.allAgents;
|
|
},
|
|
// A failed list is not a failed screen - an id can still be typed.
|
|
error: () => (this.agentOptions = [])
|
|
});
|
|
}
|
|
|
|
/** Matches on code or name, since an officer may know either. */
|
|
private filterAgents(term: string): void {
|
|
const needle = term.toLowerCase();
|
|
const match = (agent: any) =>
|
|
String(agent.agentCode || '')
|
|
.toLowerCase()
|
|
.includes(needle) ||
|
|
String(agent.agentNameId || '')
|
|
.toLowerCase()
|
|
.includes(needle) ||
|
|
String(agent.id) === term;
|
|
|
|
if (this.allAgents.length > 0) {
|
|
this.agentOptions = this.allAgents.filter(match);
|
|
return;
|
|
}
|
|
this.organizationService.getAgents().subscribe({
|
|
next: (agents: any[]) => {
|
|
this.allAgents = agents || [];
|
|
this.agentOptions = this.allAgents.filter(match);
|
|
},
|
|
error: () => (this.agentOptions = [])
|
|
});
|
|
}
|
|
|
|
displayAgent = (value: any): string => {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
return typeof value === 'object' ? `${value.agentCode} — ${value.agentNameId}` : String(value);
|
|
};
|
|
|
|
/** A minor's account names a guardian, found the same way the customer is. */
|
|
private wireGuardianLookup(): void {
|
|
this.partyForm
|
|
.get('guardianClientId')
|
|
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
|
.subscribe((value) => {
|
|
this.loadedGuardianName = '';
|
|
|
|
if (value && typeof value === 'object') {
|
|
this.guardianOptions = [];
|
|
this.loadedGuardianName = value.displayName || '';
|
|
return;
|
|
}
|
|
|
|
const typed = String(value ?? '').trim();
|
|
if (!typed) {
|
|
this.guardianOptions = [];
|
|
return;
|
|
}
|
|
|
|
if (/^\d+$/.test(typed)) {
|
|
this.clientsService.getClientData(typed, true).subscribe({
|
|
next: (client: any) => (this.loadedGuardianName = client?.displayName || ''),
|
|
error: () => undefined
|
|
});
|
|
}
|
|
this.clientsService.getFilteredClients('displayName', 'ASC', false, typed).subscribe({
|
|
next: (response: any) => {
|
|
const found = response?.pageItems || response || [];
|
|
this.guardianOptions = Array.isArray(found) ? found.slice(0, 25) : [];
|
|
},
|
|
error: () => (this.guardianOptions = [])
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sub Type is picked from the DDS Account Type master. Once a bank runs a dozen schemes a plain
|
|
* dropdown is a scroll, so it filters on either the code or the scheme's name as you type.
|
|
*/
|
|
private wireSubTypeSearch(): void {
|
|
this.partyForm.get('subType')?.valueChanges.subscribe((value) => {
|
|
// A new account takes its product's rate, which the maturity preview is worked out on. The
|
|
// server sets the same rate when it saves; an account being edited already has it.
|
|
const scheme = this.subTypes.find((type) => type.accountTypeCode === value);
|
|
if (scheme && !this.partyId) {
|
|
this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0);
|
|
}
|
|
const term = String(value ?? '')
|
|
.toLowerCase()
|
|
.trim();
|
|
this.subTypeOptions = !term
|
|
? this.subTypes
|
|
: this.subTypes.filter(
|
|
(type) =>
|
|
String(type.accountTypeCode || '')
|
|
.toLowerCase()
|
|
.includes(term) ||
|
|
String(type.accountTypeDescription || '')
|
|
.toLowerCase()
|
|
.includes(term) ||
|
|
String(type.accountTypeGroup || '')
|
|
.toLowerCase()
|
|
.includes(term)
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Opening the box should offer everything, not just what the current value happens to match. */
|
|
showAllSubTypes(): void {
|
|
this.subTypeOptions = this.subTypes;
|
|
}
|
|
|
|
displaySubType = (code: any): string => {
|
|
if (!code) {
|
|
return '';
|
|
}
|
|
const found = this.subTypes.find((type) => type.accountTypeCode === code);
|
|
return found ? `${found.accountTypeDescription} (${found.accountTypeCode})` : String(code);
|
|
};
|
|
|
|
// ----------------------------------------------------------- conditionals
|
|
|
|
private wireConditionalRules(): void {
|
|
this.partyForm.get('adultMinor')?.valueChanges.subscribe(() => this.applyAdultMinorRules());
|
|
this.partyForm.get('advCode')?.valueChanges.subscribe(() => this.applyAdvCodeRules());
|
|
this.partyForm.get('lien')?.valueChanges.subscribe(() => this.applyLienRules());
|
|
this.partyForm.get('lienReason')?.valueChanges.subscribe(() => this.applyLienRules());
|
|
this.partyForm.get('nomineeApplicable')?.valueChanges.subscribe(() => this.applyNomineeRules());
|
|
// Switching the split re-rules every nominee already on the form, not just the ones added after
|
|
// - a clerk who names three nominees and then picks Percentage must still be asked for shares.
|
|
this.partyForm.get('nomineeSplitMode')?.valueChanges.subscribe((mode) => {
|
|
this.nominees.controls.forEach((group) => this.applySplitModeTo(group, mode));
|
|
});
|
|
this.applyAdultMinorRules();
|
|
this.applyAdvCodeRules();
|
|
}
|
|
|
|
/**
|
|
* A minor's account has to say when the holder comes of age and who operates it until then; on an
|
|
* adult's account those four fields are meaningless, so they stay disabled and empty.
|
|
*/
|
|
private applyAdultMinorRules(): void {
|
|
const isMinor = this.partyForm.get('adultMinor')?.value === 'Minor';
|
|
for (const controlName of CreatePartyComponent.MINOR_FIELDS) {
|
|
const control = this.partyForm.get(controlName);
|
|
if (!control) {
|
|
continue;
|
|
}
|
|
if (isMinor) {
|
|
control.enable({ emitEvent: false });
|
|
// minAccountOpt is an optional note on how the account may be operated.
|
|
if (controlName !== 'minAccountOpt') {
|
|
control.setValidators(Validators.required);
|
|
}
|
|
} else {
|
|
control.clearValidators();
|
|
control.setValue('', { emitEvent: false });
|
|
control.disable({ emitEvent: false });
|
|
this.loadedGuardianName = '';
|
|
}
|
|
control.updateValueAndValidity({ emitEvent: false });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transfer To Saving means the maturity proceeds land in a savings account, so the account has to
|
|
* name which one. Any other Adv Code leaves nothing to credit, and the three fields go away.
|
|
*/
|
|
private applyAdvCodeRules(): void {
|
|
const transfers = this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING;
|
|
for (const controlName of CreatePartyComponent.CREDIT_TO_FIELDS) {
|
|
const control = this.partyForm.get(controlName);
|
|
if (!control) {
|
|
continue;
|
|
}
|
|
if (transfers) {
|
|
control.setValidators(Validators.required);
|
|
} else {
|
|
control.clearValidators();
|
|
control.setValue('', { emitEvent: false });
|
|
}
|
|
control.updateValueAndValidity({ emitEvent: false });
|
|
}
|
|
}
|
|
|
|
get creditToRequired(): boolean {
|
|
return this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING;
|
|
}
|
|
|
|
get isMinorAccount(): boolean {
|
|
return this.partyForm.get('adultMinor')?.value === 'Minor';
|
|
}
|
|
|
|
get nomineesRequired(): boolean {
|
|
return this.partyForm.get('nomineeApplicable')?.value === 'Yes';
|
|
}
|
|
|
|
/**
|
|
* Which entry in the list a stored reason belongs to.
|
|
*
|
|
* Anything not on the list was typed under "Other", including reasons saved before there was a
|
|
* list - so it comes back as "Other" with the original words intact rather than being lost to a
|
|
* dropdown that has no room for it.
|
|
*/
|
|
private listedLienReason(stored: string | null | undefined): string {
|
|
if (!stored) {
|
|
return '';
|
|
}
|
|
return this.lienReasons.includes(stored) ? stored : 'Other';
|
|
}
|
|
|
|
/** True while this account's money is being held, which is what asks for a reason. */
|
|
get lienHeld(): boolean {
|
|
return this.partyForm?.get('lien')?.value === 'Yes';
|
|
}
|
|
|
|
/**
|
|
* A held account has to say what it is held for; a free one has nothing to explain.
|
|
*
|
|
* The reason is cleared when the hold is lifted rather than left behind, so the form never reads
|
|
* "Lien: No" beside an explanation of a hold that no longer exists - the server clears it for the
|
|
* same reason.
|
|
*/
|
|
private applyLienRules(): void {
|
|
const reason = this.partyForm.get('lienReason');
|
|
const other = this.partyForm.get('lienReasonOther');
|
|
if (!reason || !other) {
|
|
return;
|
|
}
|
|
|
|
if (this.lienHeld) {
|
|
reason.setValidators(Validators.required);
|
|
} else {
|
|
reason.clearValidators();
|
|
reason.setValue('', { emitEvent: false });
|
|
}
|
|
reason.updateValueAndValidity({ emitEvent: false });
|
|
|
|
// Only "Other" asks for words of its own - picking it and leaving it blank says no more than
|
|
// leaving the whole thing blank did.
|
|
if (this.lienHeld && this.lienReasonIsOther) {
|
|
other.setValidators([
|
|
Validators.required,
|
|
Validators.maxLength(200)
|
|
]);
|
|
} else {
|
|
other.clearValidators();
|
|
other.setValue('', { emitEvent: false });
|
|
}
|
|
other.updateValueAndValidity({ emitEvent: false });
|
|
}
|
|
|
|
/** True while the clerk has chosen to write the reason themselves. */
|
|
get lienReasonIsOther(): boolean {
|
|
return this.partyForm?.get('lienReason')?.value === 'Other';
|
|
}
|
|
|
|
/** Saying an account has a nominee but naming none would leave the nomination incomplete. */
|
|
private applyNomineeRules(): void {
|
|
if (this.nomineesRequired && this.nominees.length === 0) {
|
|
this.addNominee();
|
|
}
|
|
if (!this.nomineesRequired) {
|
|
this.nominees.clear();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------- calculations
|
|
|
|
/**
|
|
* Maturity date and amount follow from the opening date and the tenure, so they are recomputed
|
|
* whenever one of those changes rather than being typed - which is why the legacy screen greyed
|
|
* them out.
|
|
*/
|
|
private wireMaturityCalculation(): void {
|
|
// The term drives everything downstream, so changing it refills Days as well.
|
|
for (const controlName of [
|
|
'openingDate',
|
|
'month'
|
|
]) {
|
|
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm());
|
|
}
|
|
// Days typed by hand stands: it only moves the maturity date when no month was given.
|
|
this.partyForm.get('days')?.valueChanges.subscribe(() => {
|
|
this.recalculateMaturityDate();
|
|
this.recalculateMaturityAmount();
|
|
});
|
|
for (const controlName of [
|
|
'dailyDepositAmount',
|
|
'interestRate'
|
|
]) {
|
|
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount());
|
|
}
|
|
}
|
|
|
|
private recalculateTerm(): void {
|
|
this.recalculateMaturityDate();
|
|
// Days is not refilled from the term: whether collection runs every day or skips Sundays and
|
|
// holidays is the bank's own rule, and guessing it would quietly put a wrong instalment count -
|
|
// and so a wrong maturity amount - in front of the customer. It is only checked, below.
|
|
this.partyForm.get('days')?.updateValueAndValidity({ emitEvent: false });
|
|
this.recalculateMaturityAmount();
|
|
}
|
|
|
|
private recalculateMaturityDate(): void {
|
|
const raw = this.partyForm.getRawValue();
|
|
const openingDate = this.toDate(raw.openingDate);
|
|
const months = Number(raw.month) || 0;
|
|
const days = Number(raw.days) || 0;
|
|
|
|
if (!openingDate || (months <= 0 && days <= 0)) {
|
|
this.partyForm.get('maturityDate')?.setValue('', { emitEvent: false });
|
|
return;
|
|
}
|
|
|
|
const maturity = new Date(openingDate.getTime());
|
|
if (months > 0) {
|
|
maturity.setMonth(maturity.getMonth() + months);
|
|
} else {
|
|
maturity.setDate(maturity.getDate() + days);
|
|
}
|
|
this.partyForm.get('maturityDate')?.setValue(maturity, { emitEvent: false });
|
|
}
|
|
|
|
/** Calendar days the term covers, once a term in months has been given. */
|
|
get termDays(): number | null {
|
|
const raw = this.partyForm.getRawValue();
|
|
const openingDate = this.toDate(raw.openingDate);
|
|
const months = Number(raw.month) || 0;
|
|
if (!openingDate || months <= 0) {
|
|
return null;
|
|
}
|
|
const maturity = new Date(openingDate.getTime());
|
|
maturity.setMonth(maturity.getMonth() + months);
|
|
return Math.round((maturity.getTime() - openingDate.getTime()) / (24 * 60 * 60 * 1000));
|
|
}
|
|
|
|
/**
|
|
* More instalments than there are days in the term is not a bank rule to be checked, it is
|
|
* arithmetic: 365 daily collections cannot happen inside three months. Left unchecked it puts a
|
|
* three-month maturity date beside a maturity amount worked out over a year, and the customer is
|
|
* quoted a figure the account will never reach.
|
|
*
|
|
* Fewer instalments than days is normal and passes silently - that is just a bank that does not
|
|
* collect on Sundays and holidays.
|
|
*/
|
|
private withinTerm(): ValidatorFn {
|
|
return (control: AbstractControl): ValidationErrors | null => {
|
|
const term = this.termDays;
|
|
const days = Number(control.value) || 0;
|
|
if (term === null || days <= 0 || days <= term) {
|
|
return null;
|
|
}
|
|
return { exceedsTerm: { term } };
|
|
};
|
|
}
|
|
|
|
private recalculateMaturityAmount(): void {
|
|
const raw = this.partyForm.getRawValue();
|
|
this.partyForm
|
|
.get('maturityAmount')
|
|
?.setValue(
|
|
this.maturityAmountOf(
|
|
Number(raw.dailyDepositAmount) || 0,
|
|
Number(raw.days) || 0,
|
|
Number(raw.interestRate) || 0
|
|
),
|
|
{ emitEvent: false }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Standard recurring-deposit simple interest: each daily instalment earns for the time left to
|
|
* maturity, so the first earns on the full term and the last on none. That averages out to
|
|
* n(n+1)/2 instalment-days of interest on top of the n instalments themselves.
|
|
*
|
|
* At a zero rate this is simply the total of the instalments.
|
|
*/
|
|
private maturityAmountOf(dailyAmount: number, days: number, ratePercent: number): number {
|
|
if (dailyAmount <= 0 || days <= 0) {
|
|
return 0;
|
|
}
|
|
const principal = dailyAmount * days;
|
|
const interest = ((dailyAmount * days * (days + 1)) / 2) * (ratePercent / 100) * (1 / 365);
|
|
return Math.round((principal + interest) * 100) / 100;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- nominees
|
|
|
|
get nominees(): UntypedFormArray {
|
|
return this.partyForm.get('nominees') as UntypedFormArray;
|
|
}
|
|
|
|
private buildNomineeGroup(): UntypedFormGroup {
|
|
const group = this.formBuilder.group({
|
|
nominationDate: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
name: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
// A nominee the bank cannot reach, or whose age it does not know, is a name and nothing more -
|
|
// and the age is what decides whether a guardian has to be named at all.
|
|
mobileNo: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.pattern(CreatePartyComponent.MOBILE_RE)
|
|
]
|
|
],
|
|
birthDate: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
relation: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
gender: [
|
|
'',
|
|
Validators.required
|
|
],
|
|
guardianName: [''],
|
|
// One of these belongs to each split and neither to both: a share under Percentage, a place
|
|
// in the order under Sequence. Required-ness is set from the split mode rather than here, so
|
|
// switching the mode does not leave a stale rule behind.
|
|
sharePercent: [''],
|
|
sequenceNo: ['']
|
|
});
|
|
|
|
this.applySplitModeTo(group, this.partyForm?.get('nomineeSplitMode')?.value);
|
|
|
|
// A nominee who is a minor cannot receive the proceeds themselves, so a guardian is named.
|
|
// Driven by the date of birth, which is also what decides whether they are a minor at all. The
|
|
// clerk is asked for the date once and the rest follows, rather than being asked a second time
|
|
// in a way that can contradict the first.
|
|
const applyGuardianRule = () => {
|
|
const guardian = group.get('guardianName');
|
|
if (!guardian) {
|
|
return;
|
|
}
|
|
if (this.isNomineeMinor(group)) {
|
|
guardian.setValidators(Validators.required);
|
|
} else {
|
|
guardian.clearValidators();
|
|
guardian.setValue('', { emitEvent: false });
|
|
}
|
|
guardian.updateValueAndValidity({ emitEvent: false });
|
|
};
|
|
|
|
// Both dates decide it: being under age is the gap between them, so a correction to either can
|
|
// turn a nominee into a minor or out of one.
|
|
group.get('birthDate')?.valueChanges.subscribe(applyGuardianRule);
|
|
group.get('nominationDate')?.valueChanges.subscribe(applyGuardianRule);
|
|
|
|
return group;
|
|
}
|
|
|
|
addNominee(): void {
|
|
this.nominees.push(this.buildNomineeGroup());
|
|
}
|
|
|
|
removeNominee(index: number): void {
|
|
this.nominees.removeAt(index);
|
|
}
|
|
|
|
/** True while the deposit is being split by share rather than by order of entitlement. */
|
|
get splitByPercentage(): boolean {
|
|
return this.partyForm?.get('nomineeSplitMode')?.value === 'Percentage';
|
|
}
|
|
|
|
/** What the shares entered so far come to, so the clerk can see the remainder as they type. */
|
|
get nomineeShareTotal(): number {
|
|
return this.nominees.controls.reduce((sum, group) => sum + Number(group.get('sharePercent')?.value || 0), 0);
|
|
}
|
|
|
|
/**
|
|
* A nomination that does not account for the whole deposit names nobody for the remainder, so the
|
|
* form will not submit one. Shown as a running total rather than only on save, because a clerk
|
|
* who has typed four shares wants to know which one to change, not that the set is wrong.
|
|
*
|
|
* Silent until at least one share has been typed. A blank form has nothing wrong with it yet, and
|
|
* opening the tab to a red warning tells the clerk off for work they have not had a chance to do.
|
|
*/
|
|
get nomineeShareError(): string | null {
|
|
if (!this.splitByPercentage || this.nominees.length === 0 || !this.anyShareEntered) {
|
|
return null;
|
|
}
|
|
const total = this.nomineeShareTotal;
|
|
if (total === 100) {
|
|
return null;
|
|
}
|
|
const shortBy = Math.round((100 - total) * 100) / 100;
|
|
return shortBy > 0 ? `${shortBy}% still to allocate` : `${Math.abs(shortBy)}% over`;
|
|
}
|
|
|
|
/** Whether the clerk has started allocating at all, which is what turns the total into a warning. */
|
|
private get anyShareEntered(): boolean {
|
|
return this.nominees.controls.some((group) => String(group.get('sharePercent')?.value ?? '').trim() !== '');
|
|
}
|
|
|
|
/** True while the deposit passes down an order of entitlement rather than being split by share. */
|
|
get splitBySequence(): boolean {
|
|
return this.partyForm?.get('nomineeSplitMode')?.value === 'Sequence';
|
|
}
|
|
|
|
/**
|
|
* That every place in the order is filled exactly once.
|
|
*
|
|
* Two nominees given the same place leaves the bank to choose between them, and a gap leaves
|
|
* nobody at that place - both are the order failing to say what it exists to say. Checked across
|
|
* the whole list, because 1 and 3 are each perfectly valid on their own.
|
|
*/
|
|
get nomineeOrderError(): string | null {
|
|
if (!this.splitBySequence || this.nominees.length === 0) {
|
|
return null;
|
|
}
|
|
const entered = this.nominees.controls
|
|
.map((group) => String(group.get('sequenceNo')?.value ?? '').trim())
|
|
.filter((value) => value !== '')
|
|
.map(Number);
|
|
|
|
if (entered.length === 0) {
|
|
return null;
|
|
}
|
|
const duplicate = entered.find((value, index) => entered.indexOf(value) !== index);
|
|
if (duplicate !== undefined) {
|
|
return `Order ${duplicate} is used twice.`;
|
|
}
|
|
if (entered.length < this.nominees.length) {
|
|
return 'Every nominee needs a place in the order.';
|
|
}
|
|
const missing = Array.from({ length: this.nominees.length }, (_, i) => i + 1).filter((n) => !entered.includes(n));
|
|
return missing.length > 0 ? `Order ${missing.join(', ')} is missing.` : null;
|
|
}
|
|
|
|
/**
|
|
* Turns on whichever of the two the split actually uses, and clears the other.
|
|
*
|
|
* Leaving the unused one populated would send the server a share on a Sequence nomination, or an
|
|
* order on a Percentage one - a second answer to a question that split does not ask.
|
|
*/
|
|
private applySplitModeTo(group: AbstractControl, splitMode: string): void {
|
|
const share = group.get('sharePercent');
|
|
const order = group.get('sequenceNo');
|
|
const byPercentage = splitMode === 'Percentage';
|
|
|
|
if (share) {
|
|
if (byPercentage) {
|
|
share.setValidators([
|
|
Validators.required,
|
|
Validators.min(0.01),
|
|
Validators.max(100)
|
|
]);
|
|
} else {
|
|
share.clearValidators();
|
|
share.setValue('', { emitEvent: false });
|
|
}
|
|
share.updateValueAndValidity({ emitEvent: false });
|
|
}
|
|
|
|
if (order) {
|
|
if (byPercentage) {
|
|
order.clearValidators();
|
|
order.setValue('', { emitEvent: false });
|
|
} else {
|
|
order.setValidators([
|
|
Validators.required,
|
|
Validators.min(1)
|
|
]);
|
|
}
|
|
order.updateValueAndValidity({ emitEvent: false });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether this nominee was under age on the day they were nominated, worked out from their date
|
|
* of birth rather than from a separate Yes/No.
|
|
*
|
|
* Measured against the nomination date, not today, so the form agrees with what the server
|
|
* stores: a nominee who has since come of age was still a minor on the day they were named, and
|
|
* the guardian named beside them belongs to that day.
|
|
*/
|
|
isNomineeMinor(control: AbstractControl): boolean {
|
|
const birth = control.get('birthDate')?.value;
|
|
const nominated = control.get('nominationDate')?.value;
|
|
if (!birth || !nominated) {
|
|
return false;
|
|
}
|
|
const birthDate = new Date(birth);
|
|
const majorityOn = new Date(nominated);
|
|
majorityOn.setFullYear(majorityOn.getFullYear() - 18);
|
|
return birthDate > majorityOn;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ actions
|
|
|
|
cancel(): void {
|
|
this.partyId = null;
|
|
this.partyStatus = 'ACTIVE';
|
|
this.linkedSavingsAccountId = null;
|
|
this.savingsAccountNo = null;
|
|
this.savingsProductName = null;
|
|
this.savingsAccountStatus = null;
|
|
this.savingsAccountBalance = null;
|
|
this.accountNo = null;
|
|
this.accountSchemeName = null;
|
|
this.accountBalance = null;
|
|
this.majorOn = null;
|
|
this.minorTurnedMajor = false;
|
|
this.loadedClientName = '';
|
|
this.loadedAgentName = '';
|
|
this.loadedGuardianName = '';
|
|
this.clientLookupFailed = false;
|
|
this.agentLookupFailed = false;
|
|
this.partyForm.reset();
|
|
this.partyForm.patchValue({
|
|
nominal: 'No',
|
|
operation: 'Self',
|
|
adultMinor: 'Adult',
|
|
lien: 'No',
|
|
lienReason: '',
|
|
isSmsActive: true,
|
|
isEmailActive: false,
|
|
freezeCode: 'Normal',
|
|
advCode: 'None',
|
|
nomineeApplicable: 'No',
|
|
nomineeSplitMode: 'Percentage',
|
|
currentBalance: 0,
|
|
openingBalance: 0,
|
|
maturityAmount: 0,
|
|
interestRate: 0
|
|
});
|
|
this.nominees.clear();
|
|
this.applyAdultMinorRules();
|
|
this.applyAdvCodeRules();
|
|
}
|
|
|
|
exit(): void {
|
|
this.router.navigate(['/accounts/dds']);
|
|
}
|
|
|
|
save(): void {
|
|
const raw = this.partyForm.getRawValue();
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const locale = this.settingsService.language.code;
|
|
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
|
|
|
|
const payload: any = {
|
|
// Either a client picked from the search list or an id typed straight in.
|
|
clientId: this.idOf(raw.clientId),
|
|
// idOf, not Number: the control now holds the picked agent object, and Number({}) is NaN.
|
|
agentId: this.idOf(raw.agentId),
|
|
officeId: raw.officeId,
|
|
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
|
|
subType: raw.subType,
|
|
lastAccount: raw.lastAccount || undefined,
|
|
nominal: raw.nominal === 'Yes',
|
|
aadharNo: raw.aadharNo || undefined,
|
|
partyName: raw.partyName,
|
|
partyName1: raw.partyName1 || undefined,
|
|
address1: raw.address1,
|
|
address2: raw.address2 || undefined,
|
|
address3: raw.address3 || undefined,
|
|
city: raw.city,
|
|
pincode: raw.pincode,
|
|
gender: raw.gender || undefined,
|
|
panCardNo: raw.panCardNo || undefined,
|
|
telephone: raw.telephone || undefined,
|
|
mobileNoSms: raw.mobileNoSms,
|
|
operation: raw.operation,
|
|
birthDate: asDate(raw.birthDate),
|
|
occupation: raw.occupation || undefined,
|
|
risk: raw.risk || undefined,
|
|
ckycId: raw.ckycId || undefined,
|
|
adultMinor: raw.adultMinor,
|
|
minorDate: asDate(raw.minorDate),
|
|
guardianClientId: raw.guardianClientId ? this.idOf(raw.guardianClientId) : undefined,
|
|
minAccountOpt: raw.minAccountOpt || undefined,
|
|
relation: raw.relation || undefined,
|
|
openingDate: asDate(raw.openingDate),
|
|
month: raw.month || undefined,
|
|
reopenDate: asDate(raw.reopenDate),
|
|
depositAmount: raw.depositAmount || undefined,
|
|
dailyDepositAmount: raw.dailyDepositAmount,
|
|
days: raw.days,
|
|
ledgerNo: raw.ledgerNo || undefined,
|
|
// No interest rate: the server gives the account its product's rate.
|
|
withdrawType: raw.withdrawType || undefined,
|
|
lien: raw.lien === 'Yes',
|
|
// Sent only when the money is actually held. Releasing it clears the reason on the
|
|
// server, so a released account does not carry an explanation of a hold it no
|
|
// longer has.
|
|
lienReason: raw.lien === 'Yes' ? (raw.lienReason === 'Other' ? raw.lienReasonOther : raw.lienReason) : undefined,
|
|
isSmsActive: !!raw.isSmsActive,
|
|
isEmailActive: !!raw.isEmailActive,
|
|
spDescription: raw.spDescription || undefined,
|
|
freezeCode: raw.freezeCode || undefined,
|
|
advCode: raw.advCode || undefined,
|
|
creditToBranch: raw.creditToBranch || undefined,
|
|
creditToAccountType: raw.creditToAccountType || undefined,
|
|
creditToAccount: raw.creditToAccount || undefined,
|
|
nomineeApplicable: raw.nomineeApplicable === 'Yes',
|
|
nomineeSplitMode: raw.nomineeSplitMode || undefined,
|
|
dateFormat,
|
|
locale,
|
|
nominees: (raw.nominees || []).map((nominee: any) => ({
|
|
// Sent through the same asDate as every other date on this form. A payload states one
|
|
// dateFormat and the server reads all of it that way, so a nominee date written in a second
|
|
// format is one the server is right to refuse.
|
|
nominationDate: asDate(nominee.nominationDate),
|
|
name: nominee.name,
|
|
mobileNo: nominee.mobileNo || undefined,
|
|
birthDate: asDate(nominee.birthDate),
|
|
relation: nominee.relation,
|
|
gender: nominee.gender,
|
|
guardianName: nominee.guardianName || undefined,
|
|
// Only the one its split actually uses is sent. The other is left out entirely, so the
|
|
// record says it was not stated rather than carrying a zero the server would have to tell
|
|
// apart from a real entitlement or a real place in the order.
|
|
sharePercent: this.splitByPercentage ? Number(nominee.sharePercent) : undefined,
|
|
sequenceNo: this.splitBySequence ? Number(nominee.sequenceNo) : undefined
|
|
}))
|
|
};
|
|
|
|
const partyLabel = raw.partyCode || raw.partyName;
|
|
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.partyId
|
|
? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.`
|
|
: `New party ${partyLabel} was sent for a manager's approval and will be created once approved.`
|
|
});
|
|
}
|
|
this.exit();
|
|
};
|
|
|
|
if (this.partyId) {
|
|
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
|
|
} else {
|
|
this.organizationService.createParty(payload).subscribe(done);
|
|
}
|
|
}
|
|
|
|
closeAccount(): void {
|
|
if (!this.partyId) {
|
|
return;
|
|
}
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const locale = this.settingsService.language.code;
|
|
this.organizationService
|
|
.closeParty(this.partyId, {
|
|
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
|
dateFormat,
|
|
locale
|
|
})
|
|
.subscribe(() => this.exit());
|
|
}
|
|
|
|
/**
|
|
* Whether this account can still be closed before its term is up.
|
|
*
|
|
* Only while the maturity date is ahead: once it has passed the account is paid out and closed on
|
|
* its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not
|
|
* checked here - that period lives on the scheme, and a copy of the rule on this screen would drift
|
|
* from the one the server applies. An account inside its lock-in is offered the button and refused
|
|
* with the date it becomes closable, which tells the officer more than a missing button would.
|
|
*/
|
|
/**
|
|
* The client and agent as ids the API understands. Both fields are autocompletes, so each holds
|
|
* either the object picked from the list or the raw text typed into it - the dialog needs the
|
|
* client to look up which accounts can take the payout, and the agent's code only to name who is
|
|
* being charged.
|
|
*/
|
|
private get clientIdOfForm(): any {
|
|
const value = this.partyForm.get('clientId')?.value;
|
|
return value && typeof value === 'object' ? value.id : value;
|
|
}
|
|
|
|
private get agentCodeOfForm(): string {
|
|
const value = this.partyForm.get('agentId')?.value;
|
|
if (value && typeof value === 'object') {
|
|
return value.agentCode;
|
|
}
|
|
// The form holds the agent's id; the dialog names them by code.
|
|
const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value));
|
|
return agent?.agentCode ?? String(value ?? '');
|
|
}
|
|
|
|
get canCloseEarly(): boolean {
|
|
const maturity = this.partyForm.get('maturityDate')?.value;
|
|
if (!maturity) {
|
|
return false;
|
|
}
|
|
const matured = maturity instanceof Date ? maturity : new Date(maturity);
|
|
return !isNaN(matured.getTime()) && matured.getTime() > Date.now();
|
|
}
|
|
|
|
/**
|
|
* Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the
|
|
* agent carries the cost - and shows what the scheme charges for each. Everything else, including
|
|
* the lock-in, is applied by the server.
|
|
*/
|
|
closeEarly(): void {
|
|
if (!this.partyId) {
|
|
return;
|
|
}
|
|
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
|
|
data: {
|
|
partyCode: this.partyForm.get('partyCode')?.value,
|
|
partyName: this.partyForm.get('partyName')?.value,
|
|
subType: this.partyForm.get('subType')?.value,
|
|
interestRate: this.partyForm.get('interestRate')?.value,
|
|
maturityDate: this.partyForm.get('maturityDate')?.value,
|
|
currentBalance: this.partyForm.get('currentBalance')?.value,
|
|
openingDate: this.partyForm.get('openingDate')?.value,
|
|
clientId: this.clientIdOfForm,
|
|
agentCode: this.agentCodeOfForm
|
|
},
|
|
width: '560px',
|
|
autoFocus: false
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((closeCommand: any) => {
|
|
if (!closeCommand) {
|
|
return;
|
|
}
|
|
const partyCode = this.partyForm.get('partyCode')?.value;
|
|
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
|
|
const withinLockin = !!closeCommand.lockinOverrideReason;
|
|
// The date goes up the way every other date on this system does - formatted to the user's own
|
|
// setting, with that setting and their locale alongside so the server reads it back the same
|
|
// way. An ordinary close sends it like this too; two ways of closing an account should not
|
|
// disagree about something as plain as what day it is.
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const locale = this.settingsService.language.code;
|
|
const request = {
|
|
...closeCommand,
|
|
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
|
dateFormat,
|
|
locale
|
|
};
|
|
(withinLockin
|
|
? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request)
|
|
: this.organizationService.prematureCloseParty(this.partyId as number, request)
|
|
).subscribe((response: any) => {
|
|
// Under maker-checker the command is only recorded, and nothing has closed yet.
|
|
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
|
|
this.alertService.alert({
|
|
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
|
|
message: awaitingApproval
|
|
? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.`
|
|
: `${partyCode} is closed.`
|
|
});
|
|
this.exit();
|
|
});
|
|
// A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's
|
|
// own error handler, which names the reason. Repeating it here would say the same thing twice.
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------ helpers
|
|
|
|
/**
|
|
* Splits a document type's name into lower-case words, so an identifier can be recognised by a
|
|
* whole word rather than a substring.
|
|
*
|
|
* Banks name these types freely - the records here carry "PAN Card" and "Aadhaar Card", while the
|
|
* code list also offers bare "PAN" and "AADHAAR" - so an exact-name match misses them. A substring
|
|
* match is worse: "COMPANY EMP ID" contains "pan", and would be filed as the customer's PAN.
|
|
*/
|
|
private wordsOf(name: any): string[] {
|
|
return String(name || '')
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9]+/)
|
|
.filter((word) => word.length > 0);
|
|
}
|
|
|
|
/**
|
|
* Indian digit grouping - 12,34,567.89 rather than 1,234,567.89. An officer reading the first
|
|
* says "twelve lakh" at a glance; the second has to be counted.
|
|
*/
|
|
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
});
|
|
|
|
/**
|
|
* A figure the system keeps rather than the officer types, shown as money: grouped, always to
|
|
* paise, and right-aligned so a column of them lines up. The control keeps the plain number.
|
|
*/
|
|
money(controlName: string): string {
|
|
const value = Number(this.partyForm.get(controlName)?.value);
|
|
return Number.isFinite(value) ? CreatePartyComponent.MONEY.format(value) : '';
|
|
}
|
|
|
|
/** A client field holds either the record picked from the search list or an id typed straight in. */
|
|
private idOf(value: any): number {
|
|
return Number(value && typeof value === 'object' ? value.id : value);
|
|
}
|
|
|
|
private setField(controlName: string, value: any): void {
|
|
if (value === null || value === undefined || value === '') {
|
|
return;
|
|
}
|
|
this.partyForm.get(controlName)?.setValue(String(value));
|
|
}
|
|
|
|
/** Fineract hands dates back as [yyyy, mm, dd] in places and as a string in others. */
|
|
private toDate(value: any): Date | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (value instanceof Date) {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value) && value.length >= 3) {
|
|
return new Date(value[0], value[1] - 1, value[2]);
|
|
}
|
|
const parsed = new Date(value);
|
|
return isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
private patchFormFromParty(party: any): void {
|
|
this.partyId = party.id;
|
|
this.partyStatus = party.status || 'ACTIVE';
|
|
this.linkedSavingsAccountId = party.savingsAccountId || null;
|
|
this.savingsAccountNo = party.savingsAccountNo || null;
|
|
this.savingsProductName = party.savingsProductName || null;
|
|
this.savingsAccountStatus = party.savingsAccountStatus || null;
|
|
this.savingsAccountBalance = party.savingsAccountBalance ?? null;
|
|
this.accountNo = party.accountNo || null;
|
|
this.accountSchemeName = party.subTypeDescription || party.subType || null;
|
|
this.accountBalance = party.currentBalance ?? null;
|
|
this.majorOn = party.majorOn || null;
|
|
this.minorTurnedMajor = party.minorTurnedMajor === true;
|
|
this.loadedClientName = party.clientName || '';
|
|
|
|
// Needed for the Credit To A/C dropdown to show the saved account as chosen rather than blank,
|
|
// so it is fetched on edit as well as when a client is first looked up.
|
|
if (party.clientId) {
|
|
this.loadCreditToAccounts(String(party.clientId));
|
|
}
|
|
this.loadedAgentName = party.agentName || '';
|
|
this.partyForm.patchValue({
|
|
clientId: party.clientId,
|
|
subType: party.subType,
|
|
agentId: party.agentId,
|
|
officeId: party.officeId,
|
|
partyCode: party.partyCode,
|
|
lastAccount: party.lastAccount,
|
|
nominal: party.nominal ? 'Yes' : 'No',
|
|
aadharNo: party.aadharNo,
|
|
partyName: party.partyName,
|
|
partyName1: party.partyName1,
|
|
address1: party.address1,
|
|
address2: party.address2,
|
|
address3: party.address3,
|
|
city: party.city,
|
|
pincode: party.pincode,
|
|
gender: party.gender,
|
|
panCardNo: party.panCardNo,
|
|
telephone: party.telephone,
|
|
mobileNoSms: party.mobileNoSms,
|
|
operation: party.operation,
|
|
birthDate: this.toDate(party.birthDate),
|
|
occupation: party.occupation,
|
|
risk: party.risk,
|
|
ckycId: party.ckycId,
|
|
adultMinor: party.adultMinor,
|
|
minorDate: this.toDate(party.minorDate),
|
|
guardianClientId: party.guardianClientId,
|
|
minAccountOpt: party.minAccountOpt,
|
|
relation: party.relation,
|
|
openingDate: this.toDate(party.openingDate),
|
|
month: party.month,
|
|
maturityDate: this.toDate(party.maturityDate),
|
|
reopenDate: this.toDate(party.reopenDate),
|
|
depositAmount: party.depositAmount,
|
|
dailyDepositAmount: party.dailyDepositAmount,
|
|
days: party.days,
|
|
ledgerNo: party.ledgerNo,
|
|
maturityAmount: party.maturityAmount,
|
|
interestRate: party.interestRate,
|
|
withdrawType: party.withdrawType,
|
|
od1Flag: party.od1Flag,
|
|
od2Flag: party.od2Flag,
|
|
lien: party.lien ? 'Yes' : 'No',
|
|
lienReason: this.listedLienReason(party.lienReason),
|
|
lienReasonOther: this.listedLienReason(party.lienReason) === 'Other' ? party.lienReason : '',
|
|
payDate: this.toDate(party.payDate),
|
|
paymentAmount: party.paymentAmount,
|
|
currentBalance: party.currentBalance,
|
|
openingBalance: party.openingBalance,
|
|
isSmsActive: party.isSmsActive,
|
|
isEmailActive: party.isEmailActive,
|
|
interestCalc: party.interestCalc,
|
|
interestDate: this.toDate(party.interestDate),
|
|
spDescription: party.spDescription,
|
|
freezeCode: party.freezeCode,
|
|
advCode: party.advCode,
|
|
creditToBranch: party.creditToBranch,
|
|
creditToAccountType: party.creditToAccountType,
|
|
creditToAccount: party.creditToAccount,
|
|
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
|
|
nomineeSplitMode: party.nomineeSplitMode
|
|
});
|
|
|
|
this.nominees.clear();
|
|
(party.nominees || []).forEach((nominee: any) => {
|
|
const group = this.buildNomineeGroup();
|
|
group.patchValue({
|
|
nominationDate: this.toDate(nominee.nominationDate),
|
|
name: nominee.name,
|
|
mobileNo: nominee.mobileNo,
|
|
birthDate: this.toDate(nominee.birthDate),
|
|
relation: nominee.relation,
|
|
gender: nominee.gender,
|
|
guardianName: nominee.guardianName,
|
|
sharePercent: nominee.sharePercent,
|
|
sequenceNo: nominee.sequenceNo
|
|
});
|
|
this.nominees.push(group);
|
|
});
|
|
|
|
this.applyAdultMinorRules();
|
|
this.applyAdvCodeRules();
|
|
|
|
// A closed party is a record to look at, not to change.
|
|
if (this.isClosed) {
|
|
this.partyForm.disable({ emitEvent: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */
|
|
function titleCase(value: any): string {
|
|
return String(value ?? '')
|
|
.toLowerCase()
|
|
.replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase())
|
|
.trim();
|
|
}
|