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:
@@ -11,298 +11,470 @@
|
||||
<button mat-stroked-button [routerLink]="['../']" class="am-back">
|
||||
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
|
||||
</button>
|
||||
<h2 class="am-header__title">Agent Master Entry</h2>
|
||||
<div class="am-header__icon"><i class="fas fa-user-tie"></i></div>
|
||||
<div class="am-header__text">
|
||||
<h2 class="am-header__title">Agent Master Entry</h2>
|
||||
<p class="am-header__sub">
|
||||
{{ agentId ? 'Update this collection agent.' : 'Register a collection agent, their accounts and guarantors.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-card">
|
||||
<form [formGroup]="agentForm">
|
||||
<!--
|
||||
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
|
||||
name, address, city, pincode and telephone from that client, as the legacy screen did, and
|
||||
also defaults the home branch and stocks the two savings-account dropdowns from that client.
|
||||
-->
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Agent Details</h3>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID or Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="clientId"
|
||||
[matAutocomplete]="clientAuto"
|
||||
#clientInput
|
||||
#clientTrigger="matAutocompleteTrigger"
|
||||
(focus)="onClientFocus()"
|
||||
placeholder="Click to see clients, or type a name or ID…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<i matSuffix class="fas fa-search am-search-ic"></i>
|
||||
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
|
||||
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (client of clientOptions; track client.id) {
|
||||
<mat-option [value]="client.id">
|
||||
<div class="am-opt">
|
||||
<span class="am-opt-id">{{ client.id }}</span>
|
||||
<span class="am-opt-main">{{ client.displayName }}</span>
|
||||
<span class="am-opt-sub">{{ client.officeName }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (clientListIsPartial) {
|
||||
<mat-option disabled class="am-opt-more">
|
||||
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (agentForm.get('clientId')?.hasError('pattern')) {
|
||||
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
|
||||
} @else if (clientAlreadyAgent) {
|
||||
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
|
||||
} @else if (clientAgeProblem) {
|
||||
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
|
||||
} @else if (clientOtherBranch) {
|
||||
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
|
||||
} @else if (clientLookupFailed) {
|
||||
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
|
||||
} @else if (loadedClientName) {
|
||||
<mat-hint>{{ loadedClientName }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<form [formGroup]="agentForm">
|
||||
<!--
|
||||
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
|
||||
name, address, city, pincode and telephone from that client, as the legacy screen did, and
|
||||
also defaults the home branch and stocks the two savings-account dropdowns from that client.
|
||||
The fields are the same ones the legacy screen had, set out over tabs so each page is short;
|
||||
they all belong to the one form, so nothing is lost by moving between tabs.
|
||||
-->
|
||||
<mat-tab-group
|
||||
class="am-tabs"
|
||||
animationDuration="200ms"
|
||||
[preserveContent]="true"
|
||||
[(selectedIndex)]="selectedTabIndex"
|
||||
>
|
||||
<!-- ── 1. Agent details ─────────────────────────────────────────────────────────── -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-id-card am-tab-icon"></i>
|
||||
Agent Details
|
||||
@if (tabHasError(0)) {
|
||||
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="am-tab-content">
|
||||
<div class="am-tab-intro">
|
||||
<h3>Agent Details</h3>
|
||||
<p>
|
||||
Choose the branch, then find the client who will act as agent - their name, address and accounts are
|
||||
filled in from them.
|
||||
</p>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<!-- Asked first: the clients offered, and whether a client can be made an agent here at all,
|
||||
depend on the branch. Which branch the agent collects out of: the one logged in to or a
|
||||
branch under it. The option carries the office's id while showing its name, so renaming
|
||||
a branch later leaves the agent where it is. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Home Branch</mat-label>
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>The branch this agent's collections belong to</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Code</mat-label>
|
||||
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Name ID</mat-label>
|
||||
<input matInput required formControlName="agentNameId" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 1)</mat-label>
|
||||
<input matInput required formControlName="agentAddress1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 2)</mat-label>
|
||||
<input matInput formControlName="agentAddress2" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent City</mat-label>
|
||||
<input matInput required formControlName="agentCity" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent PinCode</mat-label>
|
||||
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Telephone</mat-label>
|
||||
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The
|
||||
option carries the office's id while showing its name, so renaming a branch later
|
||||
leaves the agent where it is. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Home Branch</mat-label>
|
||||
<mat-select required formControlName="officeId">
|
||||
@for (office of offices; track office.id) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>The branch this agent's collections belong to</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
|
||||
balance left standing on it is this agent's and nobody else's. Required, and one agent
|
||||
to an account - an account already taken is refused by name, not by number. Only
|
||||
detail accounts are offered: a header account is a heading over others and carries no
|
||||
entries of its own. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>GL Account</mat-label>
|
||||
<mat-select required formControlName="glAccountId">
|
||||
@for (account of glAccounts; track account.id) {
|
||||
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Type first, then the number - the order the legacy screen used. The list is built from
|
||||
the client's own accounts, so every type offered is one they actually hold. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C Type</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
formControlName="agentSavingAccountType"
|
||||
(selectionChange)="onSavingsAccountTypePicked()"
|
||||
>
|
||||
@for (accountType of savingsAccountTypes; track accountType) {
|
||||
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
|
||||
open them a savings account. Saying which saves a guess. -->
|
||||
@if (savingsAccountTypes.length === 0) {
|
||||
@if (loadedClientName) {
|
||||
<mat-hint class="am-hint--error">
|
||||
{{ loadedClientName }} has no savings account — commission can only be paid into one
|
||||
</mat-hint>
|
||||
} @else {
|
||||
<mat-hint class="am-hint--error"
|
||||
>Enter a Client ID first — types come from that client's own accounts</mat-hint
|
||||
>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Picked from the client's own accounts, never typed: commission is paid into this
|
||||
account, and a number that exists only as text is a payment with nowhere to land. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C No</mat-label>
|
||||
<mat-select required formControlName="agentSavingAccountNo">
|
||||
@for (account of accountsOfChosenType; track account.id) {
|
||||
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (accountsOfChosenType.length === 0) {
|
||||
<mat-hint>Choose an A/C Type first</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Date</mat-label>
|
||||
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #openingDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Birth Date</mat-label>
|
||||
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
|
||||
<mat-datepicker #birthDatePicker></mat-datepicker>
|
||||
<mat-hint>From the client's record</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<h3>Guarantor's Detail</h3>
|
||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
|
||||
<i class="fas fa-plus"></i> Add Guarantor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (guarantors.length === 0) {
|
||||
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
||||
}
|
||||
|
||||
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
||||
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID or Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="clientId"
|
||||
[matAutocomplete]="guarantorAuto"
|
||||
#guarantorInput
|
||||
#guarantorTrigger="matAutocompleteTrigger"
|
||||
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
|
||||
placeholder="Click to see clients, or type…"
|
||||
(keydown.enter)="$event.preventDefault(); openClientSearch()"
|
||||
placeholder="Client ID, or search by name or mobile number…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<i matSuffix class="fas fa-search am-search-ic"></i>
|
||||
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (client of guarantorOptionsFor(guarantor); track client.id) {
|
||||
<mat-option [value]="client.id">
|
||||
<div class="am-opt">
|
||||
<span class="am-opt-id">{{ client.id }}</span>
|
||||
<span class="am-opt-main">{{ client.displayName }}</span>
|
||||
<span class="am-opt-sub">{{ client.officeName }}</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
|
||||
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
|
||||
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
|
||||
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
|
||||
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
|
||||
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
|
||||
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
|
||||
<!-- Opens Search Customer; picking a customer puts their id here, which fills the rest. -->
|
||||
<button
|
||||
matSuffix
|
||||
type="button"
|
||||
class="am-search-btn"
|
||||
aria-label="Search customer"
|
||||
[disabled]="agentId !== null"
|
||||
(click)="openClientSearch()"
|
||||
>
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
@if (clientNeedsSearch) {
|
||||
<mat-hint class="am-hint--error">Press search to find this client by name or mobile number</mat-hint>
|
||||
} @else if (clientAlreadyAgent) {
|
||||
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
|
||||
} @else if (clientAgeProblem) {
|
||||
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
|
||||
} @else if (clientOtherBranch) {
|
||||
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
|
||||
} @else if (clientLookupFailed) {
|
||||
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
|
||||
} @else if (loadedClientName) {
|
||||
<mat-hint>{{ loadedClientName }}</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Guarantor Name</mat-label>
|
||||
<input matInput required formControlName="guarantorName" />
|
||||
|
||||
<!-- Not a field: the server assigns the code when the agent is saved. A new agent is shown
|
||||
the code it is expected to get, worked out from the agents already on the books. -->
|
||||
<div class="am-code">
|
||||
<span class="am-code__label">Agent Code</span>
|
||||
<span class="am-code__value">{{
|
||||
(agentId ? agentForm.get('agentCode')?.value : expectedAgentCode) || '—'
|
||||
}}</span>
|
||||
<span class="am-code__hint">{{
|
||||
agentId ? 'Assigned when this agent was opened' : 'Expected - assigned when saved'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Name ID</mat-label>
|
||||
<input matInput required formControlName="agentNameId" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Not picked: a new agent is opened on the business date, and an agent being edited shows
|
||||
the date it was opened on. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 1</mat-label>
|
||||
<input matInput required formControlName="address1" />
|
||||
<mat-label>Agent Opening Date</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
readonly
|
||||
[matDatepicker]="openingDatePicker"
|
||||
formControlName="agentOpeningDate"
|
||||
/>
|
||||
<mat-datepicker #openingDatePicker></mat-datepicker>
|
||||
<mat-hint>{{ agentId ? 'The date this agent was opened' : 'Business date' }}</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 2</mat-label>
|
||||
<input matInput formControlName="address2" />
|
||||
<mat-label>Agent Birth Date</mat-label>
|
||||
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
|
||||
<mat-datepicker #birthDatePicker></mat-datepicker>
|
||||
<mat-hint>From the client's record</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ── 2. Address & contact ─────────────────────────────────────────────────────── -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-map-marker-alt am-tab-icon"></i>
|
||||
Address & Contact
|
||||
@if (tabHasError(1)) {
|
||||
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="am-tab-content">
|
||||
<div class="am-tab-intro">
|
||||
<h3>Address & Contact</h3>
|
||||
<p>Filled from the client; change anything that differs for the agent.</p>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 1)</mat-label>
|
||||
<input matInput required formControlName="agentAddress1" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Address (Line 2)</mat-label>
|
||||
<input matInput formControlName="agentAddress2" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>City</mat-label>
|
||||
<input matInput required formControlName="city" />
|
||||
<mat-label>Agent City</mat-label>
|
||||
<input matInput required formControlName="agentCity" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Pin Code</mat-label>
|
||||
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
|
||||
<mat-label>Agent PinCode</mat-label>
|
||||
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Telephone</mat-label>
|
||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
||||
<mat-label>Agent Telephone</mat-label>
|
||||
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="am-remove-guarantor"
|
||||
(click)="removeGuarantor(i)"
|
||||
matTooltip="Remove guarantor"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ── 3. Branch & accounts ─────────────────────────────────────────────────────── -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-university am-tab-icon"></i>
|
||||
Accounts
|
||||
@if (tabHasError(2)) {
|
||||
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="am-tab-content">
|
||||
<div class="am-tab-intro">
|
||||
<h3>Accounts</h3>
|
||||
<p>The ledger account the agent's cash passes through, and the savings account commission is paid into.</p>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
|
||||
balance left standing on it is this agent's and nobody else's. Required, and one agent
|
||||
to an account - an account already taken is refused by name, not by number. Only
|
||||
detail accounts are offered: a header account is a heading over others and carries no
|
||||
entries of its own. -->
|
||||
<mat-form-field appearance="outline" class="am-field am-field--full">
|
||||
<mat-label>GL Account</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
formControlName="glAccountId"
|
||||
(openedChange)="!$event && glAccountSearch.setValue('')"
|
||||
>
|
||||
<!-- Type a code or a name to narrow the list; cleared again when the list closes. -->
|
||||
<mat-option>
|
||||
<ngx-mat-select-search
|
||||
placeholderLabel="Search by code or name"
|
||||
noEntriesFoundLabel="No account found"
|
||||
[formControl]="glAccountSearch"
|
||||
></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
@for (account of filteredGlAccounts; track account.id) {
|
||||
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Type first, then the number - the order the legacy screen used. The list is built from
|
||||
the client's own accounts, so every type offered is one they actually hold. -->
|
||||
<mat-form-field appearance="outline" class="am-field am-field--wide">
|
||||
<mat-label>Agent Saving A/C Type</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
formControlName="agentSavingAccountType"
|
||||
(selectionChange)="onSavingsAccountTypePicked()"
|
||||
>
|
||||
@for (accountType of savingsAccountTypes; track accountType) {
|
||||
<mat-option [value]="accountType">{{
|
||||
savingsAccountTypeCodes[accountType] || accountType
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
|
||||
open them a savings account. Saying which saves a guess. -->
|
||||
@if (savingsAccountTypes.length === 0) {
|
||||
@if (loadedClientName) {
|
||||
<mat-hint class="am-hint--error">
|
||||
{{ loadedClientName }} has no savings account — commission can only be paid into one
|
||||
</mat-hint>
|
||||
} @else {
|
||||
<mat-hint class="am-hint--error"
|
||||
>Enter a Client ID first — types come from that client's own accounts</mat-hint
|
||||
>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Picked from the client's own accounts, never typed: commission is paid into this
|
||||
account, and a number that exists only as text is a payment with nowhere to land. -->
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Saving A/C No</mat-label>
|
||||
<mat-select required formControlName="agentSavingAccountNo" (selectionChange)="onSavingsAccountPicked()">
|
||||
@for (account of accountsOfChosenType; track account.id) {
|
||||
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (accountsOfChosenType.length === 0) {
|
||||
<mat-hint>Choose an A/C Type first</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- How the chosen account stands, read from the account itself: whether it can take the
|
||||
commission, what it holds, what is on hold and what is left to use. -->
|
||||
@if (savingsInfoLoading) {
|
||||
<div class="am-acct am-field--full am-acct--note">Reading the account…</div>
|
||||
} @else if (savingsInfoFailed) {
|
||||
<div class="am-acct am-field--full am-acct--note am-acct--error">
|
||||
Could not read this account's balance just now.
|
||||
</div>
|
||||
} @else if (savingsInfo) {
|
||||
<div class="am-acct am-field--full">
|
||||
<div class="am-acct__tile">
|
||||
<span class="am-acct__label">Account Status</span>
|
||||
<span
|
||||
class="am-acct__status"
|
||||
[class.am-acct__status--ok]="savingsInfo.statusClass === 'ok'"
|
||||
[class.am-acct__status--wait]="savingsInfo.statusClass === 'wait'"
|
||||
[class.am-acct__status--off]="savingsInfo.statusClass === 'off'"
|
||||
>
|
||||
<i class="fas fa-circle"></i>{{ savingsInfo.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-acct__tile">
|
||||
<span class="am-acct__label">Balance</span>
|
||||
<span class="am-acct__value"
|
||||
>{{ savingsInfo.symbol }}{{ savingsInfo.balance | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="am-acct__tile">
|
||||
<span class="am-acct__label">Hold</span>
|
||||
<span class="am-acct__value" [class.am-acct__value--hold]="savingsInfo.hold > 0">
|
||||
{{ savingsInfo.symbol }}{{ savingsInfo.hold | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-acct__tile am-acct__tile--accent">
|
||||
<span class="am-acct__label">Available Balance</span>
|
||||
<span class="am-acct__value"
|
||||
>{{ savingsInfo.symbol }}{{ savingsInfo.available | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- ── 4. Guarantors ────────────────────────────────────────────────────────────── -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-user-shield am-tab-icon"></i>
|
||||
Guarantors
|
||||
@if (guarantors.length > 0) {
|
||||
<span class="am-tab-count">{{ guarantors.length }}</span>
|
||||
}
|
||||
@if (tabHasError(3)) {
|
||||
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="am-tab-content">
|
||||
<div class="am-tab-intro am-tab-intro--split">
|
||||
<div>
|
||||
<h3>Guarantor's Detail</h3>
|
||||
<p>Someone other than the agent who stands behind them. Add as many as are needed.</p>
|
||||
</div>
|
||||
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
|
||||
<i class="fas fa-plus"></i> Add Guarantor
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="am-section">
|
||||
<div class="am-section__header">
|
||||
<i class="fas fa-balance-scale"></i>
|
||||
<h3>Balances</h3>
|
||||
@if (guarantors.length === 0) {
|
||||
<div class="am-empty">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<p class="am-empty-guarantors">No guarantors added yet.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
|
||||
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
|
||||
<div class="am-guarantor-head">
|
||||
<span class="am-guarantor-no">{{ i + 1 }}</span>
|
||||
<span class="am-guarantor-title">Guarantor {{ i + 1 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="am-remove-guarantor"
|
||||
(click)="removeGuarantor(i)"
|
||||
matTooltip="Remove guarantor"
|
||||
aria-label="Remove guarantor"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="am-guarantor-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Client ID or Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="clientId"
|
||||
(keydown.enter)="$event.preventDefault(); openGuarantorSearch(guarantor)"
|
||||
placeholder="Client ID, or search by name or mobile number…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
matSuffix
|
||||
type="button"
|
||||
class="am-search-btn"
|
||||
aria-label="Search customer"
|
||||
(click)="openGuarantorSearch(guarantor)"
|
||||
>
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
|
||||
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
|
||||
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
|
||||
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
|
||||
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
|
||||
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
|
||||
<mat-hint class="am-hint--error"
|
||||
>Press search to find this client by name or mobile number</mat-hint
|
||||
>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Guarantor Name</mat-label>
|
||||
<input matInput required formControlName="guarantorName" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Telephone</mat-label>
|
||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 1</mat-label>
|
||||
<input matInput required formControlName="address1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Address 2</mat-label>
|
||||
<input matInput formControlName="address2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>City</mat-label>
|
||||
<input matInput required formControlName="city" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Pin Code</mat-label>
|
||||
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Current Balance</mat-label>
|
||||
<input matInput formControlName="agentCurrentBalance" />
|
||||
</mat-form-field>
|
||||
</mat-tab>
|
||||
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Balance</mat-label>
|
||||
<input matInput type="number" formControlName="agentOpeningBalance" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── 5. Balances ────────────────────────────────────────────────────────────────
|
||||
Only for an agent already on the books. A new agent always starts at nothing - the form
|
||||
still sends 0 - so there is nothing to ask; the tab then shows what the agent holds. -->
|
||||
@if (agentId) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-balance-scale am-tab-icon"></i>
|
||||
Balances
|
||||
@if (tabHasError(4)) {
|
||||
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
|
||||
}
|
||||
</ng-template>
|
||||
<div class="am-tab-content">
|
||||
<div class="am-tab-intro">
|
||||
<h3>Balances</h3>
|
||||
<p>What the agent holds on the books at the start.</p>
|
||||
</div>
|
||||
<div class="am-grid">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Current Balance</mat-label>
|
||||
<input matInput formControlName="agentCurrentBalance" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="am-action-bar">
|
||||
<mat-form-field appearance="outline" class="am-field">
|
||||
<mat-label>Agent Opening Balance</mat-label>
|
||||
<input matInput type="number" formControlName="agentOpeningBalance" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
|
||||
<div class="am-action-bar">
|
||||
<div class="am-action-bar__left">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
</div>
|
||||
<div class="am-action-bar__right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
<i class="fas fa-arrow-left am-btn-ic"></i>Previous
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < tabCount - 1) {
|
||||
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
|
||||
Next<i class="fas fa-arrow-right am-btn-ic am-btn-ic--after"></i>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
@@ -313,6 +485,6 @@
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
$primary-dark: #0f4f3c;
|
||||
$primary-light: #e6f4ee;
|
||||
$danger: #b3261e;
|
||||
$border: #e2e8f0;
|
||||
$text-dark: #0f172a;
|
||||
$text-mid: #475569;
|
||||
$text-soft: #94a3b8;
|
||||
|
||||
.am-container {
|
||||
max-width: 1040px;
|
||||
@@ -14,16 +21,41 @@ $primary: #0f766e;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────────
|
||||
.am-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
|
||||
color: #fff;
|
||||
font-size: 19px;
|
||||
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
|
||||
}
|
||||
|
||||
&__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: $text-mid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,40 +63,110 @@ $primary: #0f766e;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-card {
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
.am-tabs {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
padding: 8px 24px 24px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-tab-header {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.mat-mdc-tab {
|
||||
min-width: 100px;
|
||||
padding: 0 16px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab .mdc-tab__text-label {
|
||||
color: $text-mid;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
|
||||
color: $primary-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mat-mdc-tab .mdc-tab-indicator__content--underline {
|
||||
border-color: $primary;
|
||||
border-top-width: 3px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
// Floating labels and the date picker's panel open past the edge of a tab; do not clip them.
|
||||
.mat-mdc-tab-body,
|
||||
.mat-mdc-tab-body-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.am-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
.am-tab-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
.am-tab-alert {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.am-tab-count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 8px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: $primary-light;
|
||||
color: $primary-dark;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.am-tab-content {
|
||||
padding: 22px 24px 8px;
|
||||
}
|
||||
|
||||
.am-tab-intro {
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed $border;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
&__header {
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 13px;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
&--split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fields ──────────────────────────────────────────────────────────────────
|
||||
.am-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -77,115 +179,319 @@ $primary: #0f766e;
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.am-add-guarantor {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-empty-guarantors {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.am-guarantor-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.am-limit-row {
|
||||
// How the account commission is paid into stands: status, balance, hold, available balance.
|
||||
.am-acct {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
&--note {
|
||||
display: block;
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
color: $text-mid;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
&__tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
border-right: 1px solid $border;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
&--accent {
|
||||
background: $primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: $text-soft;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
|
||||
&--hold {
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: fit-content;
|
||||
padding: 3px 12px 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #f1f5f9;
|
||||
color: $text-mid;
|
||||
|
||||
i {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--wait {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--off {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.am-remove-guarantor {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
@media (width <= 720px) {
|
||||
.am-acct {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
&__tile:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
&__tile:nth-child(-n + 2) {
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.am-action-bar {
|
||||
// The agent code, shown rather than asked for: it is assigned when the agent is saved.
|
||||
.am-code {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-height: 56px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #b7ddd0;
|
||||
border-radius: 4px;
|
||||
background: $primary-light;
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11.5px;
|
||||
color: $text-mid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
|
||||
.am-hint--error {
|
||||
color: #b3261e;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
// The search icon is a button: it opens Search Customer.
|
||||
.am-search-btn {
|
||||
margin-right: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: rgb(0 0 0 / 55%);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $primary-light;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Guarantors ──────────────────────────────────────────────────────────────
|
||||
.am-add-guarantor {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.am-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
padding: 28px 16px;
|
||||
border: 1.5px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: $text-soft;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.am-empty-guarantors {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
.am-guarantor-row {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px 6px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||
}
|
||||
|
||||
.am-guarantor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.am-guarantor-no {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: $primary-light;
|
||||
color: $primary-dark;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.am-guarantor-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
.am-guarantor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.am-remove-guarantor {
|
||||
padding: 6px 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: $danger;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action bar ──────────────────────────────────────────────────────────────
|
||||
.am-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 18px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.am-btn-ic {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
&--after {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.am-grid {
|
||||
.am-grid,
|
||||
.am-guarantor-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.am-guarantor-row,
|
||||
.am-limit-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
.am-tab-content {
|
||||
padding: 18px 14px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* One row of the client search list: the id, the name, and the branch - as on the Party form. */
|
||||
.am-opt {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (width <= 560px) {
|
||||
.am-grid,
|
||||
.am-guarantor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.am-opt-more {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.am-field--wide,
|
||||
.am-field--full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.am-opt-id {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.am-opt-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-opt-sub {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.am-search-ic {
|
||||
margin-right: 8px;
|
||||
color: rgb(0 0 0 / 40%);
|
||||
.am-action-bar__left,
|
||||
.am-action-bar__right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user