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
@@ -37,10 +37,24 @@ export interface CustomerSearchDialogData {
* need one; a screen that only needs the customer (cash exchange) passes false.
*/
requireAccount?: boolean;
/**
* Whether each customer is listed once. The search returns a row per account, which an account
* screen wants; a screen that is choosing the customer itself (an agent, a guarantor) wants each
* person once however many accounts they hold.
*/
uniqueCustomers?: boolean;
/**
* What the caller had already typed, to search for on opening. All digits is read as a client ID,
* or a mobile number once it is as long as one; anything else is a name.
*/
prefill?: string;
}
type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit';
/** An Indian mobile number is ten digits; a client ID is never that long. */
const MOBILE_NUMBER_LENGTH = 10;
@Component({
selector: 'mifosx-customer-search-dialog',
standalone: true,
@@ -77,6 +91,19 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
<mat-dialog-content class="csd-content">
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
<div class="csd-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Client ID</mat-label>
<mat-icon matPrefix>badge</mat-icon>
<input
matInput
formControlName="clientId"
placeholder="Client ID"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('clientId')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
<mat-label>Name</mat-label>
<mat-icon matPrefix>person</mat-icon>
@@ -84,6 +111,20 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Mobile Number</mat-label>
<mat-icon matPrefix>call</mat-icon>
<input
matInput
formControlName="mobileNo"
placeholder="Mobile number"
maxlength="10"
inputmode="numeric"
autocomplete="off"
(input)="digitsOnly('mobileNo')"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--wide">
<mat-label>Account Number</mat-label>
<mat-icon matPrefix>account_balance_wallet</mat-icon>
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
@@ -266,12 +307,13 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
width: 100%;
}
.csd-field--name {
.csd-field--name,
.csd-field--wide {
grid-column: span 2;
}
.csd-field--address {
grid-column: span 3;
grid-column: 1 / -1;
}
.csd-upper {
@@ -522,6 +564,7 @@ type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit
}
.csd-field--name,
.csd-field--wide,
.csd-field--address {
grid-column: span 1;
}
@@ -550,7 +593,9 @@ export class CustomerSearchDialogComponent implements OnInit {
ngOnInit(): void {
this.form = this.fb.group({
clientId: [''],
name: [''],
mobileNo: [''],
accountNumber: [''],
aadhaarNumber: [''],
panNumber: [''],
@@ -560,6 +605,36 @@ export class CustomerSearchDialogComponent implements OnInit {
fixedDeposit: [false],
recurringDeposit: [false]
});
// What the caller had typed already is searched for straight away, so opening the dialog from a
// half-filled field does not mean typing it a second time.
const typed = (this.data?.prefill ?? '').trim();
if (typed) {
const field = this.fieldFor(typed);
this.form.get(field)?.setValue(typed);
this.onSearch();
}
}
/**
* Which field something typed belongs in. Digits alone are a client ID - they are short - until
* they are as long as a mobile number, and anything with letters in it is a name.
*/
private fieldFor(typed: string): 'clientId' | 'mobileNo' | 'name' {
if (!/^\d+$/.test(typed)) {
return 'name';
}
return typed.length >= MOBILE_NUMBER_LENGTH ? 'mobileNo' : 'clientId';
}
/** Keeps a numeric field to digits however it was filled - typed, pasted or dropped. */
digitsOnly(name: string): void {
const control = this.form.get(name);
const value = String(control?.value ?? '');
const digits = value.replace(/\D/g, '');
if (value !== digits) {
control?.setValue(digits, { emitEvent: false });
}
}
/** The account screens need an account to open; a caller that only needs the customer opts out. */
@@ -627,14 +702,25 @@ export class CustomerSearchDialogComponent implements OnInit {
if (v.fixedDeposit) accountTypes.push('FIXED_DEPOSIT');
if (v.recurringDeposit) accountTypes.push('RECURRING_DEPOSIT');
if (!v.name && !v.accountNumber && !v.aadhaarNumber && !v.panNumber && !v.address && accountTypes.length === 0) {
if (
!v.clientId &&
!v.name &&
!v.mobileNo &&
!v.accountNumber &&
!v.aadhaarNumber &&
!v.panNumber &&
!v.address &&
accountTypes.length === 0
) {
this.snackBar.open('Enter at least one search field, or check an account type.', 'Close', { duration: 3000 });
return;
}
this.loading = true;
let params = new HttpParams();
if (v.clientId) params = params.set('clientId', v.clientId);
if (v.name) params = params.set('name', v.name);
if (v.mobileNo) params = params.set('mobileNo', v.mobileNo);
if (v.accountNumber) params = params.set('accountNumber', v.accountNumber);
if (v.aadhaarNumber) params = params.set('aadhaarNumber', v.aadhaarNumber);
if (v.panNumber) params = params.set('panNumber', v.panNumber);
@@ -643,7 +729,7 @@ export class CustomerSearchDialogComponent implements OnInit {
this.http.get<CustomerSearchResult[]>('/customer-search', { params }).subscribe({
next: (data) => {
this.results = data || [];
this.results = this.data?.uniqueCustomers ? this.onePerCustomer(data || []) : data || [];
this.searched = true;
this.loading = false;
},
@@ -654,9 +740,17 @@ export class CustomerSearchDialogComponent implements OnInit {
});
}
/** The search answers per account; one row per customer is the first of each person's rows. */
private onePerCustomer(rows: CustomerSearchResult[]): CustomerSearchResult[] {
const seen = new Set<number>();
return rows.filter((row) => !seen.has(row.clientId) && !!seen.add(row.clientId));
}
onClear(): void {
this.form.reset({
clientId: '',
name: '',
mobileNo: '',
accountNumber: '',
aadhaarNumber: '',
panNumber: '',