feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view

- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 10:50:03 +05:30
co-authored by Claude Sonnet 5.5
parent 357f666421
commit 293b671966
45 changed files with 5973 additions and 1292 deletions
@@ -7,9 +7,10 @@
*/
/** Angular Imports */
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
UntypedFormArray,
Validators,
@@ -26,6 +27,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { 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';
@@ -33,10 +35,19 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search';
import { 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 === '') {
@@ -67,7 +78,8 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective,
MatAutocompleteModule
MatTabsModule,
NgxMatSelectSearchModule
]
})
export class CreateAgentComponent implements OnInit {
@@ -76,17 +88,14 @@ export class CreateAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
private changeDetector = inject(ChangeDetectorRef);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
private dialog = inject(MatDialog);
/**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
@@ -118,11 +127,6 @@ export class CreateAgentComponent implements OnInit {
private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60;
/** The agent's client list without the clients who are already agents. */
private notYetAgents(clients: any[]): any[] {
return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]);
}
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
@@ -130,17 +134,99 @@ export class CreateAgentComponent implements OnInit {
clientLookupFailed = false;
/**
* Clients to pick from, as on the Party form: the first page when the field is focused, then
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id.
* 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.
*/
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
clientNeedsSearch = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
/**
* 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')}`;
}
/**
@@ -175,6 +261,31 @@ export class CreateAgentComponent implements OnInit {
*/
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 savings product a DDS customer's own scheme account is opened under, read from the setting
* that names it rather than guessed from a product name a bank is free to change.
@@ -202,9 +313,10 @@ export class CreateAgentComponent implements OnInit {
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+$/)
Validators.pattern(/^\d{1,9}$/)
]
],
agentCode: [
@@ -246,8 +358,10 @@ export class CreateAgentComponent implements OnInit {
'',
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.
@@ -271,13 +385,11 @@ export class CreateAgentComponent implements OnInit {
});
ngOnInit(): void {
// A different branch has different clients: the list is read again, and a client already keyed
// is checked against the new branch.
// A client already keyed is checked against a different branch when that is chosen.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
@@ -291,6 +403,7 @@ export class CreateAgentComponent implements OnInit {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []);
},
error: () => (this.agentCodeByClientId = {})
});
@@ -325,7 +438,14 @@ export class CreateAgentComponent implements OnInit {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
// agent pointed at one would fail on their first collection rather than here.
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
//
// 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 = [])
});
@@ -342,22 +462,63 @@ export class CreateAgentComponent implements OnInit {
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
// A name is something to search for, not a client yet - the fields stay until one is picked.
if (typed && !/^\d+$/.test(typed)) {
this.searchClients(typed);
// 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;
}
if (!typed) {
this.showClientList();
}
this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity();
// And the agent's own client leaves (or rejoins) each row's list.
if (!row.get('clientId')?.value) {
this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients));
}
}
});
}
/**
* 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);
}
});
}
@@ -372,7 +533,7 @@ export class CreateAgentComponent implements OnInit {
'',
[
Validators.required,
Validators.pattern(/^\d+$/),
Validators.pattern(/^\d{1,9}$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
@@ -398,26 +559,16 @@ export class CreateAgentComponent implements OnInit {
});
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
// Searched by name like the agent's own client field; a picked or keyed id fills the row.
// 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 (typed && !/^\d+$/.test(typed)) {
searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
});
if (this.needsSearch(typed)) {
return;
}
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed);
if (!typed) {
// Emptied: the list comes back rather than leaving nothing to pick from.
this.onGuarantorFocus(group);
}
// Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) {
if (row !== group) {
@@ -429,59 +580,6 @@ export class CreateAgentComponent implements OnInit {
return group;
}
/**
* Each guarantor row's list lives on the row itself, as a plain array the template reads - the same
* way the agent's own list works - so one row's typing never fills another's.
*/
private setGuarantorOptions(group: AbstractControl, options: any[]): void {
(group as any).clientOptions = options;
}
guarantorOptionsFor(group: AbstractControl): any[] {
return (group as any).clientOptions || GUARANTOR_NO_OPTIONS;
}
/**
* Opens the client list on a guarantor row as soon as it is focused, as the agent's field does.
* The list is filled before the panel is asked to open, since a panel opened empty stays shut.
*/
onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
if (trigger) {
// Kept so a search answered later can open this row's list too.
(group as any).panel = { trigger, input };
}
if (group.get('clientId')?.value) {
return;
}
const show = () => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
};
if (this.initialClients.length > 0) {
show();
return;
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
show();
},
error: () => undefined
});
}
/** Clients who can stand guarantor on this row: not the agent, and not already on another row. */
private eligibleGuarantors(group: AbstractControl, found: any[]): any[] {
const agentClientId = String(this.agentForm.get('clientId')?.value ?? '');
const taken = new Set(
this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? ''))
);
return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id)));
}
/** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
@@ -551,8 +649,6 @@ export class CreateAgentComponent implements OnInit {
addGuarantor(): void {
const group = this.buildGuarantorGroup();
this.guarantors.push(group);
// Ready before the field is ever focused, so the list is there the moment it is clicked.
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
}
removeGuarantor(index: number): void {
@@ -573,60 +669,6 @@ export class CreateAgentComponent implements OnInit {
'agentPinCode'
];
/** Opens the client list when the field is focused, unless a client is already in it. */
onClientFocus(): void {
if (this.agentId || this.agentForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the first page of clients, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.agentId) {
return;
}
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
return;
}
// The chosen branch's clients only, since an agent is opened for the branch they belong to.
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
},
// A failed list is not a failed screen - the id can still be typed.
error: () => (this.clientOptions = [])
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = this.notYetAgents(found).slice(0, 25);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
});
}
/**
* Shows a list that has just arrived. An autocomplete opened while its list was still empty stays
* shut when the options come in, so the screen is refreshed and the panel opened again - but only
* on the field still being typed in, never one the officer has moved on from.
*/
private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
this.changeDetector.detectChanges();
if (trigger && input && document.activeElement === input) {
trigger.openPanel();
}
}
/**
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
* They always show the client currently entered: changing the id replaces them, clearing it
@@ -645,6 +687,8 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
@@ -722,6 +766,9 @@ export class CreateAgentComponent implements OnInit {
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 = [];
@@ -738,6 +785,63 @@ export class CreateAgentComponent implements OnInit {
*/
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 {
@@ -790,9 +894,14 @@ export class CreateAgentComponent implements OnInit {
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 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 {
@@ -910,9 +1019,6 @@ function asDate(value: any): Date | null {
return null;
}
/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */
const GUARANTOR_NO_OPTIONS: any[] = [];
/** Full years between a date of birth and a day, or null without a date of birth. */
function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) {