DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes

This commit is contained in:
tarunmalviya
2026-10-01 13:08:52 +05:30
parent d47e0860f6
commit 885b39d970
48 changed files with 5878 additions and 374 deletions
@@ -32,6 +32,8 @@ 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';
@@ -84,6 +86,7 @@ export class CreatePartyComponent implements OnInit {
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);
@@ -130,6 +133,11 @@ export class CreatePartyComponent implements OnInit {
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.
@@ -271,9 +279,20 @@ export class CreatePartyComponent implements OnInit {
'gender',
'birthDate',
'telephone',
'mobileNoSms'
'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',
@@ -323,7 +342,10 @@ export class CreatePartyComponent implements OnInit {
'No',
Validators.required
],
aadharNo: ['', Validators.pattern(CreatePartyComponent.AADHAAR_RE)],
aadharNo: [
'',
Validators.pattern(CreatePartyComponent.AADHAAR_RE)
],
partyName: [
'',
Validators.required
@@ -347,8 +369,14 @@ export class CreatePartyComponent implements OnInit {
]
],
gender: [''],
panCardNo: ['', Validators.pattern(CreatePartyComponent.PAN_RE)],
telephone: ['', Validators.pattern(CreatePartyComponent.TELEPHONE_RE)],
panCardNo: [
'',
Validators.pattern(CreatePartyComponent.PAN_RE)
],
telephone: [
'',
Validators.pattern(CreatePartyComponent.TELEPHONE_RE)
],
mobileNoSms: [
'',
[
@@ -363,7 +391,10 @@ export class CreatePartyComponent implements OnInit {
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: ['', Validators.pattern(CreatePartyComponent.CKYC_RE)],
ckycId: [
'',
Validators.pattern(CreatePartyComponent.CKYC_RE)
],
adultMinor: [
'Adult',
Validators.required
@@ -511,8 +542,10 @@ export class CreatePartyComponent implements OnInit {
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'})`;
this.officesError =
error?.error?.defaultUserMessage ||
error?.error?.developerMessage ||
`Could not load branches (${error?.status || 'no response'})`;
}
});
@@ -547,6 +580,8 @@ export class CreatePartyComponent implements OnInit {
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;
@@ -574,13 +609,9 @@ export class CreatePartyComponent implements OnInit {
}
private searchClients(term: string): void {
this.clientsService.getFilteredClients('displayName', 'ASC', false, term).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.showingClientList = false;
this.clientOptions = Array.isArray(found) ? found.slice(0, 25) : [];
},
error: () => (this.clientOptions = [])
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
@@ -624,13 +655,54 @@ export class CreatePartyComponent implements OnInit {
next: (client: any) => {
this.loadedClientName = client?.displayName || knownName || '';
this.clientLookupFailed = false;
this.setField('partyName', client?.displayName);
this.setField('mobileNoSms', client?.mobileNo);
this.setField('gender', client?.gender?.name || client?.gender);
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');
@@ -692,11 +764,11 @@ export class CreatePartyComponent implements OnInit {
continue;
}
if (words.includes('aadhaar') || words.includes('aadhar')) {
this.setField('aadharNo', documentKey);
this.fillFromClientRecord('aadharNo', documentKey);
} else if (words.includes('pan')) {
this.setField('panCardNo', documentKey);
this.fillFromClientRecord('panCardNo', documentKey);
} else if (words.includes('ckyc')) {
this.setField('ckycId', documentKey);
this.fillFromClientRecord('ckycId', documentKey);
}
}
},
@@ -713,6 +785,39 @@ export class CreatePartyComponent implements OnInit {
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) => {
@@ -720,11 +825,11 @@ export class CreatePartyComponent implements OnInit {
if (!address) {
return;
}
this.setField('address1', address.addressLine1 || address.street);
this.setField('address2', address.addressLine2);
this.setField('address3', address.addressLine3);
this.setField('city', address.city);
this.setField('pincode', address.postalCode);
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
@@ -802,8 +907,12 @@ export class CreatePartyComponent implements OnInit {
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.agentCode || '')
.toLowerCase()
.includes(needle) ||
String(agent.agentNameId || '')
.toLowerCase()
.includes(needle) ||
String(agent.id) === term;
if (this.allAgents.length > 0) {
@@ -868,14 +977,28 @@ export class CreatePartyComponent implements OnInit {
*/
private wireSubTypeSearch(): void {
this.partyForm.get('subType')?.valueChanges.subscribe((value) => {
const term = String(value ?? '').toLowerCase().trim();
// 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)
String(type.accountTypeCode || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeDescription || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeGroup || '')
.toLowerCase()
.includes(term)
);
});
}
@@ -970,8 +1093,6 @@ export class CreatePartyComponent implements OnInit {
return this.partyForm.get('nomineeApplicable')?.value === 'Yes';
}
/**
* Which entry in the list a stored reason belongs to.
*
@@ -1051,7 +1172,10 @@ export class CreatePartyComponent implements OnInit {
*/
private wireMaturityCalculation(): void {
// The term drives everything downstream, so changing it refills Days as well.
for (const controlName of ['openingDate', 'month']) {
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.
@@ -1059,7 +1183,10 @@ export class CreatePartyComponent implements OnInit {
this.recalculateMaturityDate();
this.recalculateMaturityAmount();
});
for (const controlName of ['dailyDepositAmount', 'interestRate']) {
for (const controlName of [
'dailyDepositAmount',
'interestRate'
]) {
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount());
}
}
@@ -1131,7 +1258,11 @@ export class CreatePartyComponent implements OnInit {
this.partyForm
.get('maturityAmount')
?.setValue(
this.maturityAmountOf(Number(raw.dailyDepositAmount) || 0, Number(raw.days) || 0, Number(raw.interestRate) || 0),
this.maturityAmountOf(
Number(raw.dailyDepositAmount) || 0,
Number(raw.days) || 0,
Number(raw.interestRate) || 0
),
{ emitEvent: false }
);
}
@@ -1372,6 +1503,9 @@ export class CreatePartyComponent implements OnInit {
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 = '';
@@ -1451,7 +1585,7 @@ export class CreatePartyComponent implements OnInit {
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
ledgerNo: raw.ledgerNo || undefined,
interestRate: raw.interestRate || 0,
// 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
@@ -1489,10 +1623,24 @@ export class CreatePartyComponent implements OnInit {
}))
};
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(() => this.exit());
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else {
this.organizationService.createParty(payload).subscribe(() => this.exit());
this.organizationService.createParty(payload).subscribe(done);
}
}
@@ -1504,7 +1652,7 @@ export class CreatePartyComponent implements OnInit {
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
@@ -1533,7 +1681,12 @@ export class CreatePartyComponent implements OnInit {
private get agentCodeOfForm(): string {
const value = this.partyForm.get('agentId')?.value;
return value && typeof value === 'object' ? value.agentCode : String(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 {
@@ -1562,6 +1715,7 @@ export class CreatePartyComponent implements OnInit {
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
},
@@ -1573,20 +1727,35 @@ export class CreatePartyComponent implements OnInit {
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;
this.organizationService
.prematureCloseParty(this.partyId as number, {
...closeCommand,
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
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.
});
@@ -1662,6 +1831,9 @@ export class CreatePartyComponent implements OnInit {
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 || '';
@@ -1762,3 +1934,11 @@ export class CreatePartyComponent implements OnInit {
}
}
}
/** 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();
}