DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
|
||||
/**
|
||||
* Finds clients by name whatever case the officer types it in.
|
||||
*
|
||||
* The platform's name filter matches anywhere in the name but only in the exact case typed, so
|
||||
* "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term
|
||||
* is tried as typed, capitalised word by word, and in upper and lower case, and the results merged.
|
||||
* Done here rather than by changing the platform's search, which every other screen relies on.
|
||||
*/
|
||||
export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable<any[]> {
|
||||
const variants = Array.from(
|
||||
new Set([
|
||||
term,
|
||||
term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()),
|
||||
term.toUpperCase(),
|
||||
term.toLowerCase()
|
||||
])
|
||||
);
|
||||
return forkJoin(
|
||||
variants.map((variant) =>
|
||||
clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe(
|
||||
map((response: any) => {
|
||||
const found = response?.pageItems || response || [];
|
||||
return Array.isArray(found) ? found : [];
|
||||
}),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
)
|
||||
).pipe(
|
||||
map((lists: any[][]) => {
|
||||
const byId = new Map<number, any>();
|
||||
for (const client of lists.flat()) {
|
||||
byId.set(client.id, client);
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName)));
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,9 @@
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-id-card"></i>
|
||||
<h3>Customer & Scheme</h3>
|
||||
<span class="pm-section__sub">Who this account is for, which scheme it runs under, and which agent collects for it</span>
|
||||
<span class="pm-section__sub"
|
||||
>Who this account is for, which scheme it runs under, and which agent collects for it</span
|
||||
>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
@@ -42,7 +44,11 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
|
||||
<mat-autocomplete
|
||||
#clientAuto="matAutocomplete"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
[displayWith]="displayClient"
|
||||
>
|
||||
@for (client of clientOptions; track client.id) {
|
||||
<mat-option [value]="client">
|
||||
<div class="pm-opt">
|
||||
@@ -81,7 +87,11 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #subTypeAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displaySubType">
|
||||
<mat-autocomplete
|
||||
#subTypeAuto="matAutocomplete"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
[displayWith]="displaySubType"
|
||||
>
|
||||
@for (type of subTypeOptions; track type.id) {
|
||||
<mat-option [value]="type.accountTypeCode">
|
||||
<div class="pm-opt">
|
||||
@@ -114,7 +124,11 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #agentAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayAgent">
|
||||
<mat-autocomplete
|
||||
#agentAuto="matAutocomplete"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
[displayWith]="displayAgent"
|
||||
>
|
||||
@for (agent of agentOptions; track agent.id) {
|
||||
<mat-option [value]="agent">
|
||||
<div class="pm-opt">
|
||||
@@ -163,20 +177,37 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
The customer's real deposit account. Three states, and the difference matters: an
|
||||
account is open, this party predates DDS opening them, or the party is new and the
|
||||
account is about to be opened. It used to read "Linked (id 36)", which is a number
|
||||
only the database means anything by.
|
||||
-->
|
||||
@if (savingsAccountNo) {
|
||||
@if (accountNo) {
|
||||
<!-- The party is the DDS account now, so its number, scheme and balance are its own. -->
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">DDS Account</label>
|
||||
<div class="pm-account">
|
||||
<span class="pm-account-no">{{ accountNo }}</span>
|
||||
<span class="pm-account-meta">
|
||||
{{ accountSchemeName }} ·
|
||||
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{
|
||||
partyStatus === 'ACTIVE' ? 'Active' : partyStatus
|
||||
}}</span>
|
||||
· {{ accountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (savingsAccountNo) {
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">DDS Account</label>
|
||||
<div class="pm-account">
|
||||
<span class="pm-account-no">{{ savingsAccountNo }}</span>
|
||||
<span class="pm-account-meta">
|
||||
{{ savingsProductName }} ·
|
||||
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{ savingsAccountStatus }}</span>
|
||||
<span [class.pm-account-live]="savingsAccountStatus === 'Active'">{{
|
||||
savingsAccountStatus
|
||||
}}</span>
|
||||
· {{ savingsAccountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -224,7 +255,7 @@
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Party Name<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="partyName" />
|
||||
<input matInput required formControlName="partyName" [readonly]="isFromClient('partyName')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -234,39 +265,45 @@
|
||||
<input matInput formControlName="partyName1" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 1<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="address1" />
|
||||
<input matInput required formControlName="address1" [readonly]="isFromClient('address1')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 2</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="address2" />
|
||||
<input matInput formControlName="address2" [readonly]="isFromClient('address2')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f pm-f--wide">
|
||||
<label class="pm-f-lbl">Address 3</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="address3" />
|
||||
<input matInput formControlName="address3" [readonly]="isFromClient('address3')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">City<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="city" />
|
||||
<input matInput required formControlName="city" [readonly]="isFromClient('city')" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Pincode<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="pincode" mifosxRestrictInput="digits" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="pincode"
|
||||
[readonly]="isFromClient('pincode')"
|
||||
mifosxRestrictInput="digits"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -284,35 +321,61 @@
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Telephone</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="telephone"
|
||||
[readonly]="isFromClient('telephone')"
|
||||
mifosxRestrictInput="phone"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Mobile No (SMS)<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="mobileNoSms" mifosxRestrictInput="digits" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="mobileNoSms"
|
||||
[readonly]="isFromClient('mobileNoSms')"
|
||||
mifosxRestrictInput="digits"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Aadhar No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="aadharNo" mifosxRestrictInput="aadhaar" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadharNo"
|
||||
[readonly]="isFromClient('aadharNo')"
|
||||
mifosxRestrictInput="aadhaar"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">PAN Card No</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="panCardNo" mifosxRestrictInput="pan" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="panCardNo"
|
||||
[readonly]="isFromClient('panCardNo')"
|
||||
mifosxRestrictInput="pan"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">CKYC ID</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput formControlName="ckycId" mifosxRestrictInput="digits" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="ckycId"
|
||||
[readonly]="isFromClient('ckycId')"
|
||||
mifosxRestrictInput="digits"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +393,12 @@
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Birth Date</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [matDatepicker]="birthPicker" formControlName="birthDate" />
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="birthPicker"
|
||||
formControlName="birthDate"
|
||||
[readonly]="isFromClient('birthDate')"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="birthPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #birthPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
@@ -364,7 +432,9 @@
|
||||
<div class="pm-section__header">
|
||||
<i class="fas fa-user-friends"></i>
|
||||
<h3>Minor / Guardian</h3>
|
||||
<span class="pm-section__sub">Only for a minor's account — the guardian who operates it until they come of age</span>
|
||||
<span class="pm-section__sub"
|
||||
>Only for a minor's account — the guardian who operates it until they come of age</span
|
||||
>
|
||||
</div>
|
||||
<div class="pm-grid">
|
||||
<div class="pm-f">
|
||||
@@ -382,7 +452,13 @@
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Minor Date<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required [matDatepicker]="minorPicker" formControlName="minorDate" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[matDatepicker]="minorPicker"
|
||||
formControlName="minorDate"
|
||||
[readonly]="isFromClient('minorDate')"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="minorPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #minorPicker></mat-datepicker>
|
||||
<mat-hint>Turns 18 on</mat-hint>
|
||||
@@ -396,12 +472,17 @@
|
||||
matInput
|
||||
required
|
||||
formControlName="guardianClientId"
|
||||
[readonly]="isFromClient('guardianClientId')"
|
||||
[matAutocomplete]="guardianAuto"
|
||||
placeholder="Type an ID or search by name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
|
||||
<mat-autocomplete #guardianAuto="matAutocomplete" panelWidth="min(30rem, 92vw)" [displayWith]="displayClient">
|
||||
<mat-autocomplete
|
||||
#guardianAuto="matAutocomplete"
|
||||
panelWidth="min(30rem, 92vw)"
|
||||
[displayWith]="displayClient"
|
||||
>
|
||||
@for (client of guardianOptions; track client.id) {
|
||||
<mat-option [value]="client">
|
||||
<div class="pm-opt">
|
||||
@@ -428,7 +509,7 @@
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Relation<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required formControlName="relation" />
|
||||
<input matInput required formControlName="relation" [readonly]="isFromClient('relation')" />
|
||||
<mat-hint>To the minor</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@@ -535,24 +616,8 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Interest Rate</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" formControlName="interestRate" />
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Withdraw Type</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="withdrawType">
|
||||
@for (type of withdrawTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<!-- Interest Rate is the product's (Sub Type), not typed here. -->
|
||||
<!-- Withdraw Type is decided when the account is paid out, not when it is opened. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -610,7 +675,13 @@
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Please specify<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput required maxlength="200" formControlName="lienReasonOther" placeholder="e.g. Loan #1234" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
maxlength="200"
|
||||
formControlName="lienReasonOther"
|
||||
placeholder="e.g. Loan #1234"
|
||||
/>
|
||||
<mat-hint>Write the reason in your own words</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@@ -687,17 +758,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">Adv Code</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<mat-select formControlName="advCode">
|
||||
@for (code of advCodes; track code) {
|
||||
<mat-option [value]="code">{{ code }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<!-- Adv Code is decided when the account is paid out, not when it is opened. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +784,7 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
Picked from the customer's own savings accounts, never typed. The whole matured
|
||||
amount lands here, and a number that exists only as text is a payout with
|
||||
nowhere to go - which is what one party was already carrying.
|
||||
@@ -790,7 +851,10 @@
|
||||
|
||||
@if (splitByPercentage && nominees.length > 0) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
|
||||
{{ nomineeShareTotal }}% of 100% allocated@if (nomineeShareError) { — {{ nomineeShareError }} }
|
||||
{{ nomineeShareTotal }}% of 100% allocated
|
||||
@if (nomineeShareError) {
|
||||
— {{ nomineeShareError }}
|
||||
}
|
||||
</span>
|
||||
} @else if (splitBySequence && nominees.length > 0) {
|
||||
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
|
||||
@@ -865,7 +929,10 @@
|
||||
</div>
|
||||
<div class="pm-f">
|
||||
<label class="pm-f-lbl">
|
||||
Guar. Name@if (isNomineeMinor(nominee)) {<b>*</b>}
|
||||
Guar. Name
|
||||
@if (isNomineeMinor(nominee)) {
|
||||
<b>*</b>
|
||||
}
|
||||
</label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
|
||||
@@ -878,7 +945,15 @@
|
||||
<div class="pm-f pm-f--tight">
|
||||
<label class="pm-f-lbl">Share %<b>*</b></label>
|
||||
<mat-form-field appearance="outline" class="pm-field">
|
||||
<input matInput type="number" min="0.01" max="100" step="0.01" required formControlName="sharePercent" />
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="0.01"
|
||||
required
|
||||
formControlName="sharePercent"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -922,8 +997,9 @@
|
||||
<i class="fas fa-user-clock"></i>
|
||||
<span>
|
||||
This account was opened for a minor who came of age on
|
||||
<strong>{{ majorOn | dateFormat }}</strong>. The guardian can no longer
|
||||
operate it — take fresh KYC, a signature and a new mandate from the account holder.
|
||||
<strong>{{ majorOn | dateFormat }}</strong
|
||||
>. The guardian can no longer operate it — take fresh KYC, a signature and a new mandate from the account
|
||||
holder.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { ProductsService } from 'app/products/products.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { searchClientsAnyCase } from '../client-search';
|
||||
|
||||
/** Custom Components */
|
||||
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
|
||||
@@ -84,6 +86,7 @@ export class CreatePartyComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private productsService = inject(ProductsService);
|
||||
private alertService = inject(AlertService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
@@ -130,6 +133,11 @@ export class CreatePartyComponent implements OnInit {
|
||||
savingsAccountStatus: string | null = null;
|
||||
savingsAccountBalance: number | null = null;
|
||||
|
||||
/** The DDS account's own number, scheme and balance - the party is the account. */
|
||||
accountNo: string | null = null;
|
||||
accountSchemeName: string | null = null;
|
||||
accountBalance: number | null = null;
|
||||
|
||||
/**
|
||||
* A minor account holder who has come of age. The guardian's authority ended on that day, so the
|
||||
* branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field.
|
||||
@@ -271,9 +279,20 @@ export class CreatePartyComponent implements OnInit {
|
||||
'gender',
|
||||
'birthDate',
|
||||
'telephone',
|
||||
'mobileNoSms'
|
||||
'mobileNoSms',
|
||||
'occupation',
|
||||
'risk',
|
||||
'aadharNo',
|
||||
'panCardNo',
|
||||
'ckycId',
|
||||
'relation',
|
||||
'minorDate',
|
||||
'guardianClientId'
|
||||
];
|
||||
|
||||
/** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */
|
||||
private filledFromClient = new Set<string>();
|
||||
|
||||
private static readonly MINOR_FIELDS = [
|
||||
'minorDate',
|
||||
'guardianClientId',
|
||||
@@ -323,7 +342,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
'No',
|
||||
Validators.required
|
||||
],
|
||||
aadharNo: ['', Validators.pattern(CreatePartyComponent.AADHAAR_RE)],
|
||||
aadharNo: [
|
||||
'',
|
||||
Validators.pattern(CreatePartyComponent.AADHAAR_RE)
|
||||
],
|
||||
partyName: [
|
||||
'',
|
||||
Validators.required
|
||||
@@ -347,8 +369,14 @@ export class CreatePartyComponent implements OnInit {
|
||||
]
|
||||
],
|
||||
gender: [''],
|
||||
panCardNo: ['', Validators.pattern(CreatePartyComponent.PAN_RE)],
|
||||
telephone: ['', Validators.pattern(CreatePartyComponent.TELEPHONE_RE)],
|
||||
panCardNo: [
|
||||
'',
|
||||
Validators.pattern(CreatePartyComponent.PAN_RE)
|
||||
],
|
||||
telephone: [
|
||||
'',
|
||||
Validators.pattern(CreatePartyComponent.TELEPHONE_RE)
|
||||
],
|
||||
mobileNoSms: [
|
||||
'',
|
||||
[
|
||||
@@ -363,7 +391,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
birthDate: [''],
|
||||
occupation: [''],
|
||||
risk: [''],
|
||||
ckycId: ['', Validators.pattern(CreatePartyComponent.CKYC_RE)],
|
||||
ckycId: [
|
||||
'',
|
||||
Validators.pattern(CreatePartyComponent.CKYC_RE)
|
||||
],
|
||||
adultMinor: [
|
||||
'Adult',
|
||||
Validators.required
|
||||
@@ -511,8 +542,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.officesError = this.offices.length === 0 ? 'No branches on file' : '';
|
||||
},
|
||||
error: (error: any) => {
|
||||
this.officesError = error?.error?.defaultUserMessage || error?.error?.developerMessage
|
||||
|| `Could not load branches (${error?.status || 'no response'})`;
|
||||
this.officesError =
|
||||
error?.error?.defaultUserMessage ||
|
||||
error?.error?.developerMessage ||
|
||||
`Could not load branches (${error?.status || 'no response'})`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -547,6 +580,8 @@ export class CreatePartyComponent implements OnInit {
|
||||
for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) {
|
||||
this.partyForm.get(controlName)?.setValue('');
|
||||
}
|
||||
this.filledFromClient.clear();
|
||||
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
|
||||
this.loadedClientName = '';
|
||||
this.clientLookupFailed = false;
|
||||
|
||||
@@ -574,13 +609,9 @@ export class CreatePartyComponent implements OnInit {
|
||||
}
|
||||
|
||||
private searchClients(term: string): void {
|
||||
this.clientsService.getFilteredClients('displayName', 'ASC', false, term).subscribe({
|
||||
next: (response: any) => {
|
||||
const found = response?.pageItems || response || [];
|
||||
this.showingClientList = false;
|
||||
this.clientOptions = Array.isArray(found) ? found.slice(0, 25) : [];
|
||||
},
|
||||
error: () => (this.clientOptions = [])
|
||||
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
|
||||
this.showingClientList = false;
|
||||
this.clientOptions = found.slice(0, 25);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -624,13 +655,54 @@ export class CreatePartyComponent implements OnInit {
|
||||
next: (client: any) => {
|
||||
this.loadedClientName = client?.displayName || knownName || '';
|
||||
this.clientLookupFailed = false;
|
||||
this.setField('partyName', client?.displayName);
|
||||
this.setField('mobileNoSms', client?.mobileNo);
|
||||
this.setField('gender', client?.gender?.name || client?.gender);
|
||||
this.fillFromClientRecord('partyName', client?.displayName);
|
||||
this.fillFromClientRecord('mobileNoSms', client?.mobileNo);
|
||||
this.fillFromClientRecord('telephone', client?.alternateMobileNo);
|
||||
// Alerts go where the client can actually be reached: SMS with a mobile on record, email with
|
||||
// an email address on record.
|
||||
this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo);
|
||||
this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress);
|
||||
// The client record says FEMALE; this form's list says Female.
|
||||
this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender));
|
||||
const dateOfBirth = this.toDate(client?.dateOfBirth);
|
||||
if (dateOfBirth) {
|
||||
this.partyForm.get('birthDate')?.setValue(dateOfBirth);
|
||||
this.filledFromClient.add('birthDate');
|
||||
}
|
||||
// The client's own occupation, offered even when this form's short list does not carry it.
|
||||
const occupation = client?.occupation?.name || client?.occupationCategory?.name;
|
||||
if (occupation) {
|
||||
if (!this.occupations.includes(occupation)) {
|
||||
this.occupations = [
|
||||
...this.occupations,
|
||||
occupation
|
||||
];
|
||||
}
|
||||
this.fillFromClientRecord('occupation', occupation);
|
||||
}
|
||||
// A minor on the client record is a minor account here, with the same guardian.
|
||||
this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult');
|
||||
// Adult or minor is the client record's, checked when the client was opened - not changed here.
|
||||
this.partyForm.get('adultMinor')?.disable({ emitEvent: false });
|
||||
this.filledFromClient.add('adultMinor');
|
||||
if (client?.isMinor) {
|
||||
if (dateOfBirth) {
|
||||
// The day they come of age - eighteen is majority in India.
|
||||
const majority = new Date(dateOfBirth);
|
||||
majority.setFullYear(majority.getFullYear() + 18);
|
||||
this.partyForm.get('minorDate')?.setValue(majority);
|
||||
this.filledFromClient.add('minorDate');
|
||||
}
|
||||
if (client?.guardianClientId) {
|
||||
this.partyForm
|
||||
.get('guardianClientId')
|
||||
?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' });
|
||||
this.filledFromClient.add('guardianClientId');
|
||||
}
|
||||
this.fillFromClientRecord('relation', titleCase(client?.guardianRelation));
|
||||
}
|
||||
this.loadClientRisk(String(clientId));
|
||||
// Nominees are not brought across: the officer records them for this account.
|
||||
// Proceeds usually go back to an account at the customer's own branch, so that is offered
|
||||
// first - any branch can still be picked, since the account may well be at another.
|
||||
const creditToBranch = this.partyForm.get('creditToBranch');
|
||||
@@ -692,11 +764,11 @@ export class CreatePartyComponent implements OnInit {
|
||||
continue;
|
||||
}
|
||||
if (words.includes('aadhaar') || words.includes('aadhar')) {
|
||||
this.setField('aadharNo', documentKey);
|
||||
this.fillFromClientRecord('aadharNo', documentKey);
|
||||
} else if (words.includes('pan')) {
|
||||
this.setField('panCardNo', documentKey);
|
||||
this.fillFromClientRecord('panCardNo', documentKey);
|
||||
} else if (words.includes('ckyc')) {
|
||||
this.setField('ckycId', documentKey);
|
||||
this.fillFromClientRecord('ckycId', documentKey);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -713,6 +785,39 @@ export class CreatePartyComponent implements OnInit {
|
||||
return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a value from the client record, which was checked when the client was opened, and locks
|
||||
* the field so it is not typed - and checked - a second time. A value the client does not have is
|
||||
* left for the officer to enter.
|
||||
*/
|
||||
private fillFromClientRecord(controlName: string, value: any): void {
|
||||
if (value === null || value === undefined || String(value).trim() === '') {
|
||||
return;
|
||||
}
|
||||
this.setField(controlName, value);
|
||||
this.filledFromClient.add(controlName);
|
||||
}
|
||||
|
||||
/** Whether a field holds a value taken from the client record, and so is shown read-only. */
|
||||
isFromClient(controlName: string): boolean {
|
||||
return this.filledFromClient.has(controlName);
|
||||
}
|
||||
|
||||
/** The client's risk category, from their KYC record. */
|
||||
private loadClientRisk(clientId: string): void {
|
||||
this.clientsService.getClientKyc(clientId, true).subscribe({
|
||||
next: (kyc: any) => {
|
||||
// kycRiskCategory.medium -> Medium
|
||||
const code = String(kyc?.riskCategoryCode || '')
|
||||
.split('.')
|
||||
.pop();
|
||||
this.fillFromClientRecord('risk', titleCase(code));
|
||||
},
|
||||
// A client without a KYC record just leaves the risk to be chosen.
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
private loadClientAddress(clientId: string): void {
|
||||
this.clientsService.getClientAddressData(clientId, true).subscribe({
|
||||
next: (addresses: any) => {
|
||||
@@ -720,11 +825,11 @@ export class CreatePartyComponent implements OnInit {
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
this.setField('address1', address.addressLine1 || address.street);
|
||||
this.setField('address2', address.addressLine2);
|
||||
this.setField('address3', address.addressLine3);
|
||||
this.setField('city', address.city);
|
||||
this.setField('pincode', address.postalCode);
|
||||
this.fillFromClientRecord('address1', address.addressLine1 || address.street);
|
||||
this.fillFromClientRecord('address2', address.addressLine2);
|
||||
this.fillFromClientRecord('address3', address.addressLine3);
|
||||
this.fillFromClientRecord('city', address.city);
|
||||
this.fillFromClientRecord('pincode', address.postalCode);
|
||||
},
|
||||
// A client with no address on file is normal - the officer just types it.
|
||||
error: () => undefined
|
||||
@@ -802,8 +907,12 @@ export class CreatePartyComponent implements OnInit {
|
||||
private filterAgents(term: string): void {
|
||||
const needle = term.toLowerCase();
|
||||
const match = (agent: any) =>
|
||||
String(agent.agentCode || '').toLowerCase().includes(needle) ||
|
||||
String(agent.agentNameId || '').toLowerCase().includes(needle) ||
|
||||
String(agent.agentCode || '')
|
||||
.toLowerCase()
|
||||
.includes(needle) ||
|
||||
String(agent.agentNameId || '')
|
||||
.toLowerCase()
|
||||
.includes(needle) ||
|
||||
String(agent.id) === term;
|
||||
|
||||
if (this.allAgents.length > 0) {
|
||||
@@ -868,14 +977,28 @@ export class CreatePartyComponent implements OnInit {
|
||||
*/
|
||||
private wireSubTypeSearch(): void {
|
||||
this.partyForm.get('subType')?.valueChanges.subscribe((value) => {
|
||||
const term = String(value ?? '').toLowerCase().trim();
|
||||
// A new account takes its product's rate, which the maturity preview is worked out on. The
|
||||
// server sets the same rate when it saves; an account being edited already has it.
|
||||
const scheme = this.subTypes.find((type) => type.accountTypeCode === value);
|
||||
if (scheme && !this.partyId) {
|
||||
this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0);
|
||||
}
|
||||
const term = String(value ?? '')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
this.subTypeOptions = !term
|
||||
? this.subTypes
|
||||
: this.subTypes.filter(
|
||||
(type) =>
|
||||
String(type.accountTypeCode || '').toLowerCase().includes(term) ||
|
||||
String(type.accountTypeDescription || '').toLowerCase().includes(term) ||
|
||||
String(type.accountTypeGroup || '').toLowerCase().includes(term)
|
||||
String(type.accountTypeCode || '')
|
||||
.toLowerCase()
|
||||
.includes(term) ||
|
||||
String(type.accountTypeDescription || '')
|
||||
.toLowerCase()
|
||||
.includes(term) ||
|
||||
String(type.accountTypeGroup || '')
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -970,8 +1093,6 @@ export class CreatePartyComponent implements OnInit {
|
||||
return this.partyForm.get('nomineeApplicable')?.value === 'Yes';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Which entry in the list a stored reason belongs to.
|
||||
*
|
||||
@@ -1051,7 +1172,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
*/
|
||||
private wireMaturityCalculation(): void {
|
||||
// The term drives everything downstream, so changing it refills Days as well.
|
||||
for (const controlName of ['openingDate', 'month']) {
|
||||
for (const controlName of [
|
||||
'openingDate',
|
||||
'month'
|
||||
]) {
|
||||
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm());
|
||||
}
|
||||
// Days typed by hand stands: it only moves the maturity date when no month was given.
|
||||
@@ -1059,7 +1183,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.recalculateMaturityDate();
|
||||
this.recalculateMaturityAmount();
|
||||
});
|
||||
for (const controlName of ['dailyDepositAmount', 'interestRate']) {
|
||||
for (const controlName of [
|
||||
'dailyDepositAmount',
|
||||
'interestRate'
|
||||
]) {
|
||||
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount());
|
||||
}
|
||||
}
|
||||
@@ -1131,7 +1258,11 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.partyForm
|
||||
.get('maturityAmount')
|
||||
?.setValue(
|
||||
this.maturityAmountOf(Number(raw.dailyDepositAmount) || 0, Number(raw.days) || 0, Number(raw.interestRate) || 0),
|
||||
this.maturityAmountOf(
|
||||
Number(raw.dailyDepositAmount) || 0,
|
||||
Number(raw.days) || 0,
|
||||
Number(raw.interestRate) || 0
|
||||
),
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
@@ -1372,6 +1503,9 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.savingsProductName = null;
|
||||
this.savingsAccountStatus = null;
|
||||
this.savingsAccountBalance = null;
|
||||
this.accountNo = null;
|
||||
this.accountSchemeName = null;
|
||||
this.accountBalance = null;
|
||||
this.majorOn = null;
|
||||
this.minorTurnedMajor = false;
|
||||
this.loadedClientName = '';
|
||||
@@ -1451,7 +1585,7 @@ export class CreatePartyComponent implements OnInit {
|
||||
dailyDepositAmount: raw.dailyDepositAmount,
|
||||
days: raw.days,
|
||||
ledgerNo: raw.ledgerNo || undefined,
|
||||
interestRate: raw.interestRate || 0,
|
||||
// No interest rate: the server gives the account its product's rate.
|
||||
withdrawType: raw.withdrawType || undefined,
|
||||
lien: raw.lien === 'Yes',
|
||||
// Sent only when the money is actually held. Releasing it clears the reason on the
|
||||
@@ -1489,10 +1623,24 @@ export class CreatePartyComponent implements OnInit {
|
||||
}))
|
||||
};
|
||||
|
||||
const partyLabel = raw.partyCode || raw.partyName;
|
||||
const done = (response: any) => {
|
||||
// Under maker-checker the command is only recorded, and nothing has been saved yet.
|
||||
if (!!response?.commandId && response?.resourceId == null) {
|
||||
this.alertService.alert({
|
||||
type: 'Sent for Approval',
|
||||
message: this.partyId
|
||||
? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.`
|
||||
: `New party ${partyLabel} was sent for a manager's approval and will be created once approved.`
|
||||
});
|
||||
}
|
||||
this.exit();
|
||||
};
|
||||
|
||||
if (this.partyId) {
|
||||
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
|
||||
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
|
||||
} else {
|
||||
this.organizationService.createParty(payload).subscribe(() => this.exit());
|
||||
this.organizationService.createParty(payload).subscribe(done);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1504,7 +1652,7 @@ export class CreatePartyComponent implements OnInit {
|
||||
const locale = this.settingsService.language.code;
|
||||
this.organizationService
|
||||
.closeParty(this.partyId, {
|
||||
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
@@ -1533,7 +1681,12 @@ export class CreatePartyComponent implements OnInit {
|
||||
|
||||
private get agentCodeOfForm(): string {
|
||||
const value = this.partyForm.get('agentId')?.value;
|
||||
return value && typeof value === 'object' ? value.agentCode : String(value ?? '');
|
||||
if (value && typeof value === 'object') {
|
||||
return value.agentCode;
|
||||
}
|
||||
// The form holds the agent's id; the dialog names them by code.
|
||||
const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value));
|
||||
return agent?.agentCode ?? String(value ?? '');
|
||||
}
|
||||
|
||||
get canCloseEarly(): boolean {
|
||||
@@ -1562,6 +1715,7 @@ export class CreatePartyComponent implements OnInit {
|
||||
interestRate: this.partyForm.get('interestRate')?.value,
|
||||
maturityDate: this.partyForm.get('maturityDate')?.value,
|
||||
currentBalance: this.partyForm.get('currentBalance')?.value,
|
||||
openingDate: this.partyForm.get('openingDate')?.value,
|
||||
clientId: this.clientIdOfForm,
|
||||
agentCode: this.agentCodeOfForm
|
||||
},
|
||||
@@ -1573,20 +1727,35 @@ export class CreatePartyComponent implements OnInit {
|
||||
if (!closeCommand) {
|
||||
return;
|
||||
}
|
||||
const partyCode = this.partyForm.get('partyCode')?.value;
|
||||
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
|
||||
const withinLockin = !!closeCommand.lockinOverrideReason;
|
||||
// The date goes up the way every other date on this system does - formatted to the user's own
|
||||
// setting, with that setting and their locale alongside so the server reads it back the same
|
||||
// way. An ordinary close sends it like this too; two ways of closing an account should not
|
||||
// disagree about something as plain as what day it is.
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const locale = this.settingsService.language.code;
|
||||
this.organizationService
|
||||
.prematureCloseParty(this.partyId as number, {
|
||||
...closeCommand,
|
||||
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
.subscribe(() => this.exit());
|
||||
const request = {
|
||||
...closeCommand,
|
||||
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
(withinLockin
|
||||
? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request)
|
||||
: this.organizationService.prematureCloseParty(this.partyId as number, request)
|
||||
).subscribe((response: any) => {
|
||||
// Under maker-checker the command is only recorded, and nothing has closed yet.
|
||||
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
|
||||
this.alertService.alert({
|
||||
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
|
||||
message: awaitingApproval
|
||||
? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.`
|
||||
: `${partyCode} is closed.`
|
||||
});
|
||||
this.exit();
|
||||
});
|
||||
// A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's
|
||||
// own error handler, which names the reason. Repeating it here would say the same thing twice.
|
||||
});
|
||||
@@ -1662,6 +1831,9 @@ export class CreatePartyComponent implements OnInit {
|
||||
this.savingsProductName = party.savingsProductName || null;
|
||||
this.savingsAccountStatus = party.savingsAccountStatus || null;
|
||||
this.savingsAccountBalance = party.savingsAccountBalance ?? null;
|
||||
this.accountNo = party.accountNo || null;
|
||||
this.accountSchemeName = party.subTypeDescription || party.subType || null;
|
||||
this.accountBalance = party.currentBalance ?? null;
|
||||
this.majorOn = party.majorOn || null;
|
||||
this.minorTurnedMajor = party.minorTurnedMajor === true;
|
||||
this.loadedClientName = party.clientName || '';
|
||||
@@ -1762,3 +1934,11 @@ export class CreatePartyComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */
|
||||
function titleCase(value: any): string {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
<div class="pl-kpi-l">Outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Money the bank is holding on matured accounts it has not yet paid out">
|
||||
<div
|
||||
class="pl-kpi pl-kpi--teal"
|
||||
matTooltip="Money the bank is holding on matured accounts it has not yet paid out"
|
||||
>
|
||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalPayable | number: '1.2-2' }}</div>
|
||||
@@ -82,7 +85,7 @@
|
||||
<td>
|
||||
<div class="pl-product-meta">
|
||||
<div class="pl-product-name">{{ party.partyCode }}</div>
|
||||
<div class="pl-product-sub dfc-account">{{ party.savingsAccountNo || '—' }}</div>
|
||||
<div class="pl-product-sub dfc-account">{{ party.accountNo || party.savingsAccountNo || '—' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ party.partyName }}</td>
|
||||
|
||||
@@ -141,12 +141,20 @@ export class DueForClosureComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
this.organizationService.payOutParty(party.id, payoutCommand).subscribe({
|
||||
next: () => {
|
||||
next: (result: any) => {
|
||||
if (awaitingApproval(result)) {
|
||||
this.alertService.alert({
|
||||
type: 'Sent for Approval',
|
||||
message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
|
||||
});
|
||||
this.load();
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'Party Paid Out',
|
||||
message: payoutCommand.toSavingsAccountNo
|
||||
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo}. Close the account once you have checked it.`
|
||||
: `${party.partyCode} paid into Matured Deposits Payable, ready to transfer out. Close the account once you have checked it.`
|
||||
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.`
|
||||
: `${party.partyCode} parked in Sundry in the customer's name and the account is closed.`
|
||||
});
|
||||
this.load();
|
||||
}
|
||||
@@ -179,16 +187,28 @@ export class DueForClosureComponent implements OnInit {
|
||||
const locale = this.settingsService.language.code;
|
||||
this.organizationService
|
||||
.closeParty(party.id, {
|
||||
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
||||
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.alertService.alert({ type: 'Party Closed', message: `${party.partyCode} is closed.` });
|
||||
next: (result: any) => {
|
||||
this.alertService.alert(
|
||||
awaitingApproval(result)
|
||||
? {
|
||||
type: 'Sent for Approval',
|
||||
message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.`
|
||||
}
|
||||
: { type: 'Party Closed', message: `${party.partyCode} is closed.` }
|
||||
);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */
|
||||
function awaitingApproval(result: any): boolean {
|
||||
return !!result?.commandId && result?.resourceId == null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
paid in full, waiting to be closed.
|
||||
}
|
||||
</span>
|
||||
<span class="pm-due-banner__cta">Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon></span>
|
||||
<span class="pm-due-banner__cta"
|
||||
>Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon
|
||||
></span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -59,7 +61,10 @@
|
||||
<div class="pl-kpi-l">Open Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--purple" matTooltip="What the agents should collect between them on a full collection day">
|
||||
<div
|
||||
class="pl-kpi pl-kpi--purple"
|
||||
matTooltip="What the agents should collect between them on a full collection day"
|
||||
>
|
||||
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
|
||||
@@ -79,7 +84,11 @@
|
||||
<div class="pl-toolbar">
|
||||
<div class="pl-search">
|
||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||
<input type="text" (input)="applyFilter($any($event.target).value)" placeholder="Search by code, name or agent…" />
|
||||
<input
|
||||
type="text"
|
||||
(input)="applyFilter($any($event.target).value)"
|
||||
placeholder="Search by code, name or agent…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,14 +140,19 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
The customer's real deposit account. A party without one is money the bank's books do not
|
||||
know about, so it is called out rather than shown as a blank cell.
|
||||
-->
|
||||
<ng-container matColumnDef="savingsAccountNo">
|
||||
<th mat-header-cell *matHeaderCellDef>DDS Account</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
@if (p.savingsAccountNo) {
|
||||
@if (p.accountNo) {
|
||||
<div class="pl-product-meta">
|
||||
<div class="pm-list-account">{{ p.accountNo }}</div>
|
||||
<div class="pl-product-sub">{{ p.subTypeDescription || p.subType }}</div>
|
||||
</div>
|
||||
} @else if (p.savingsAccountNo) {
|
||||
<div class="pl-product-meta">
|
||||
<div class="pm-list-account">{{ p.savingsAccountNo }}</div>
|
||||
<div class="pl-product-sub">{{ p.savingsAccountStatus }}</div>
|
||||
|
||||
@@ -117,8 +117,24 @@ export class PartyMasterComponent implements OnInit {
|
||||
parties: any[] = [];
|
||||
filteredCount = 0;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
paginator: MatPaginator;
|
||||
sort: MatSort;
|
||||
|
||||
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
|
||||
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
|
||||
@ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) {
|
||||
this.paginator = paginator;
|
||||
if (paginator) {
|
||||
this.dataSource.paginator = paginator;
|
||||
}
|
||||
}
|
||||
|
||||
@ViewChild(MatSort) set matSort(sort: MatSort) {
|
||||
this.sort = sort;
|
||||
if (sort) {
|
||||
this.dataSource.sort = sort;
|
||||
}
|
||||
}
|
||||
|
||||
get totalCount(): number {
|
||||
return this.parties.length;
|
||||
@@ -163,7 +179,10 @@ export class PartyMasterComponent implements OnInit {
|
||||
next: (due: any) => {
|
||||
const rows = Array.isArray(due) ? due : due?.pageItems || [];
|
||||
this.dueForClosureCount = rows.length;
|
||||
this.dueForClosurePayable = rows.reduce((sum: number, party: any) => sum + Number(party.currentBalance || 0), 0);
|
||||
this.dueForClosurePayable = rows.reduce(
|
||||
(sum: number, party: any) => sum + Number(party.currentBalance || 0),
|
||||
0
|
||||
);
|
||||
},
|
||||
// The party list is the point of this screen; a failed banner must not take it down with it.
|
||||
error: () => {
|
||||
|
||||
@@ -20,11 +20,67 @@
|
||||
<span class="po-summary__label">Matured on</span>
|
||||
<span class="po-summary__value">{{ data.maturityDate | dateFormat }}</span>
|
||||
</div>
|
||||
<div class="po-summary__row po-summary__row--amount">
|
||||
<span class="po-summary__label">Amount payable</span>
|
||||
<span class="po-summary__value">{{ data.currentBalance | number: '1.2-2' }}</span>
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Opened on</span>
|
||||
<span class="po-summary__value">{{ data.openingDate | dateFormat }}</span>
|
||||
</div>
|
||||
|
||||
@if (breakdown) {
|
||||
<div class="po-breakdown">
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Deposits collected</span>
|
||||
<span class="po-summary__value">{{ breakdown.deposits | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (breakdown.transfersIn > 0) {
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Transferred in</span>
|
||||
<span class="po-summary__value">+ {{ breakdown.transfersIn | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (breakdown.transfersOut > 0) {
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Transferred out</span>
|
||||
<span class="po-summary__value">− {{ breakdown.transfersOut | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (breakdown.other !== 0) {
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">Other (opening balance / adjustments)</span>
|
||||
<span class="po-summary__value">{{ breakdown.other | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label">
|
||||
Interest @ {{ data.interestRate | number: '1.2-2' }}%
|
||||
<span class="po-summary__note"
|
||||
>{{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="po-summary__value">+ {{ breakdown.interest | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (breakdown.interestToPost > 0) {
|
||||
<div class="po-summary__row">
|
||||
<span class="po-summary__label"> Interest calculated, posted with this payout </span>
|
||||
<span class="po-summary__value">+ {{ breakdown.interestToPost | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="po-summary__row po-summary__row--amount">
|
||||
<span class="po-summary__label">Total payable</span>
|
||||
<span class="po-summary__value">{{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (interestStillToCome) {
|
||||
<p class="po-summary__note">
|
||||
Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet
|
||||
- it is added when this is paid out, so the amount paid will be a little more.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<p class="po-summary__note">
|
||||
Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more.
|
||||
</p>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
@@ -46,28 +102,57 @@
|
||||
<mat-label>Account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountNo" panelWidth="min(28rem, 90vw)">
|
||||
@for (account of ownAccounts; track account.id) {
|
||||
<mat-option [value]="account.accountNo">
|
||||
{{ account.accountNo }} · {{ account.productName }}
|
||||
</mat-option>
|
||||
<mat-option [value]="account.accountNo"> {{ account.accountNo }} · {{ account.productName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-radio-button value="other" class="po-choice">
|
||||
<span class="po-choice__title">To an account at another bank</span>
|
||||
<span class="po-choice__note">
|
||||
Held in Matured Deposits Payable, then sent by NEFT or IMPS from Fund Transfers
|
||||
</span>
|
||||
<span class="po-choice__title">Park in Sundry</span>
|
||||
<span class="po-choice__note"
|
||||
>When the customer has no account here - held in the bank's Sundry account in their name</span
|
||||
>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@if (destination === 'other') {
|
||||
<mat-form-field appearance="outline" class="po-account">
|
||||
<mat-label>Park in GL account</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="sundrySearch"
|
||||
(ngModelChange)="onSundryTyped($event)"
|
||||
[matAutocomplete]="sundryAuto"
|
||||
placeholder="Type GL code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#sundryAuto="matAutocomplete"
|
||||
panelWidth="min(28rem, 90vw)"
|
||||
[displayWith]="sundryDisplay"
|
||||
(optionSelected)="pickSundry($event.option.value)"
|
||||
>
|
||||
@for (account of sundryOptions; track account.id) {
|
||||
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
@if (sundryOptions.length === 0) {
|
||||
<mat-option disabled>No liability GL matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (!sundryGlAccountId) {
|
||||
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (destination === 'other') {
|
||||
<!-- Said plainly, because the officer will be asked by the customer when the money arrives. -->
|
||||
<div class="po-callout">
|
||||
The money comes off {{ data.partyCode }} today and sits in the bank's Matured Deposits Payable
|
||||
account until the transfer is made from Fund Transfers — so it is accounted for the whole
|
||||
time, and a failed transfer loses nothing.
|
||||
The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against
|
||||
{{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -75,5 +160,5 @@
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close>Cancel</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out & Close</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -38,6 +38,22 @@
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__note {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
/* How the amount payable is made up - one line per source, adding up to the total below. */
|
||||
.po-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.po-question {
|
||||
@@ -90,3 +106,7 @@
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.po-hint-warn {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
@@ -46,6 +47,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
MatButtonModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatAutocompleteModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
@@ -72,9 +74,90 @@ export class PayoutDialogComponent implements OnInit {
|
||||
*/
|
||||
ownAccounts: any[] = [];
|
||||
selectedAccountNo = '';
|
||||
|
||||
/** Where the balance is parked when the customer has no account here. */
|
||||
sundryAccounts: any[] = [];
|
||||
sundryGlAccountId: number | null = null;
|
||||
/** What is typed in the GL box - a code or part of a name. */
|
||||
sundrySearch = '';
|
||||
|
||||
/** The accounts matching what is typed, by GL code or name, in any case. */
|
||||
get sundryOptions(): any[] {
|
||||
const typed = this.sundrySearch.trim().toLowerCase();
|
||||
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
|
||||
return this.sundryAccounts;
|
||||
}
|
||||
return this.sundryAccounts.filter(
|
||||
(account: any) =>
|
||||
String(account.glCode).toLowerCase().includes(typed) ||
|
||||
String(account.name || '')
|
||||
.toLowerCase()
|
||||
.includes(typed)
|
||||
);
|
||||
}
|
||||
|
||||
get chosenSundry(): any {
|
||||
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
|
||||
}
|
||||
|
||||
/** Shows a picked account in the box as its code and name. */
|
||||
readonly sundryDisplay = (value: any): string =>
|
||||
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
|
||||
|
||||
sundryLabel(account: any): string {
|
||||
return account ? `${account.glCode} · ${account.name}` : '';
|
||||
}
|
||||
|
||||
pickSundry(account: any): void {
|
||||
this.sundryGlAccountId = account ? account.id : null;
|
||||
this.sundrySearch = this.sundryLabel(account);
|
||||
}
|
||||
|
||||
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
|
||||
onSundryTyped(value: any): void {
|
||||
// Picking from the list hands over the account itself rather than text.
|
||||
if (value && typeof value === 'object') {
|
||||
this.pickSundry(value);
|
||||
return;
|
||||
}
|
||||
value = String(value ?? '');
|
||||
this.sundrySearch = value;
|
||||
if (value !== this.sundryLabel(this.chosenSundry)) {
|
||||
const exact = this.sundryAccounts.find(
|
||||
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
|
||||
);
|
||||
this.sundryGlAccountId = exact ? exact.id : null;
|
||||
}
|
||||
}
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* How the amount payable was made up - what was deposited, what came in or went out by transfer,
|
||||
* and the interest - read from the account's own transactions so the officer can explain the
|
||||
* figure to the customer line by line. Null until the transactions have arrived.
|
||||
*/
|
||||
breakdown: {
|
||||
deposits: number;
|
||||
transfersIn: number;
|
||||
transfersOut: number;
|
||||
interest: number;
|
||||
other: number;
|
||||
interestToPost: number;
|
||||
total: number;
|
||||
} | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
|
||||
// and the clerk searches for it and picks it.
|
||||
this.organizationService.getDdsSundryAccounts().subscribe({
|
||||
next: (accounts: any) => (this.sundryAccounts = accounts || []),
|
||||
error: () => (this.sundryAccounts = [])
|
||||
});
|
||||
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
|
||||
next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])),
|
||||
error: () => (this.breakdown = null)
|
||||
});
|
||||
|
||||
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
|
||||
next: (accounts: any) => {
|
||||
this.ownAccounts = accounts || [];
|
||||
@@ -96,14 +179,52 @@ export class PayoutDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Interest is still to be worked out for part of the term, and the payout will add it. */
|
||||
get interestStillToCome(): boolean {
|
||||
return (
|
||||
!!this.data.interestDate &&
|
||||
!!this.data.maturityDate &&
|
||||
String(this.data.interestDate) < String(this.data.maturityDate)
|
||||
);
|
||||
}
|
||||
|
||||
private breakdownOf(transactions: any[]) {
|
||||
const sum = (test: (txn: any) => boolean) =>
|
||||
transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0);
|
||||
const signed = (type: string) =>
|
||||
sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') -
|
||||
sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT');
|
||||
const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER';
|
||||
|
||||
const deposits = signed('PARTY_TRAN');
|
||||
const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT');
|
||||
const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT');
|
||||
const interest =
|
||||
signed('INTEREST_POST') +
|
||||
signed('OD_INTEREST_POST') +
|
||||
signed('PREMATURE_AGENT_TOP_UP') +
|
||||
signed('PREMATURE_INT_RECOVERY');
|
||||
const balance = Number(this.data.currentBalance || 0);
|
||||
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
|
||||
// still add up to the balance rather than leaving the officer a gap to explain.
|
||||
const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100;
|
||||
// Interest already worked out but not yet credited is posted by the payout itself, first.
|
||||
const interestToPost = Number(this.data.interestCalc || 0);
|
||||
return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost };
|
||||
}
|
||||
|
||||
get canPayOut(): boolean {
|
||||
if (this.loading) {
|
||||
return false;
|
||||
}
|
||||
return this.destination === 'other' || !!this.selectedAccountNo;
|
||||
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
|
||||
}
|
||||
|
||||
payOut(): void {
|
||||
this.dialogRef.close(this.destination === 'own' ? { toSavingsAccountNo: this.selectedAccountNo } : {});
|
||||
this.dialogRef.close(
|
||||
this.destination === 'own'
|
||||
? { toSavingsAccountNo: this.selectedAccountNo }
|
||||
: { sundryGlAccountId: this.sundryGlAccountId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-9
@@ -47,8 +47,8 @@
|
||||
<div class="pcd-choice-detail">
|
||||
Interest recalculated at
|
||||
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
|
||||
back before the balance is paid out.
|
||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
|
||||
before the balance is paid out.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
|
||||
@@ -61,8 +61,8 @@
|
||||
<div class="pcd-choice-body">
|
||||
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
|
||||
<div class="pcd-choice-detail">
|
||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The
|
||||
balance is paid out in full.
|
||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
|
||||
paid out in full.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -86,7 +86,7 @@
|
||||
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
|
||||
Into another account of theirs at this bank
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="other">Park it for transfer to another bank</mat-radio-button>
|
||||
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||
@@ -100,19 +100,76 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (destination === 'other') {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Park in GL account</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="sundrySearch"
|
||||
(ngModelChange)="onSundryTyped($event)"
|
||||
[matAutocomplete]="sundryAuto"
|
||||
placeholder="Type GL code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#sundryAuto="matAutocomplete"
|
||||
panelWidth="min(28rem, 90vw)"
|
||||
[displayWith]="sundryDisplay"
|
||||
(optionSelected)="pickSundry($event.option.value)"
|
||||
>
|
||||
@for (account of sundryOptions; track account.id) {
|
||||
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
@if (sundryOptions.length === 0) {
|
||||
<mat-option disabled>No liability GL matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (!sundryGlAccountId) {
|
||||
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (ownAccounts.length === 0 && !loading) {
|
||||
<p class="pcd-note">
|
||||
This customer has no other account here, so the balance is parked in matured-deposits payable for the
|
||||
branch to send on.
|
||||
This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their
|
||||
name until it is paid to them.
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="pcd-warn">This closes the account. It cannot be reopened.</p>
|
||||
<!--
|
||||
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
|
||||
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
|
||||
-->
|
||||
@if (withinLockin) {
|
||||
<div class="pcd-lockin">
|
||||
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
|
||||
<p class="pcd-note">
|
||||
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
|
||||
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
|
||||
</p>
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Reason for closing within lock-in</mat-label>
|
||||
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
|
||||
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="pcd-warn">
|
||||
@if (withinLockin) {
|
||||
Once approved, this closes the account. It cannot be reopened.
|
||||
} @else {
|
||||
This closes the account. It cannot be reopened.
|
||||
}
|
||||
</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
|
||||
Close Account
|
||||
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
+20
@@ -110,3 +110,23 @@
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
|
||||
.pcd-lockin {
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgb(180 83 9 / 30%);
|
||||
background: rgb(245 158 11 / 8%);
|
||||
}
|
||||
|
||||
.pcd-lockin-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.po-hint-warn {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
+94
-2
@@ -14,6 +14,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
@@ -51,6 +52,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
MatButtonModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatAutocompleteModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
@@ -73,6 +75,61 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
|
||||
ownAccounts: any[] = [];
|
||||
selectedAccountNo = '';
|
||||
|
||||
/** Where the balance is parked when the customer has no account here. */
|
||||
sundryAccounts: any[] = [];
|
||||
sundryGlAccountId: number | null = null;
|
||||
/** What is typed in the GL box - a code or part of a name. */
|
||||
sundrySearch = '';
|
||||
|
||||
/** The accounts matching what is typed, by GL code or name, in any case. */
|
||||
get sundryOptions(): any[] {
|
||||
const typed = this.sundrySearch.trim().toLowerCase();
|
||||
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
|
||||
return this.sundryAccounts;
|
||||
}
|
||||
return this.sundryAccounts.filter(
|
||||
(account: any) =>
|
||||
String(account.glCode).toLowerCase().includes(typed) ||
|
||||
String(account.name || '')
|
||||
.toLowerCase()
|
||||
.includes(typed)
|
||||
);
|
||||
}
|
||||
|
||||
get chosenSundry(): any {
|
||||
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
|
||||
}
|
||||
|
||||
/** Shows a picked account in the box as its code and name. */
|
||||
readonly sundryDisplay = (value: any): string =>
|
||||
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
|
||||
|
||||
sundryLabel(account: any): string {
|
||||
return account ? `${account.glCode} · ${account.name}` : '';
|
||||
}
|
||||
|
||||
pickSundry(account: any): void {
|
||||
this.sundryGlAccountId = account ? account.id : null;
|
||||
this.sundrySearch = this.sundryLabel(account);
|
||||
}
|
||||
|
||||
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
|
||||
onSundryTyped(value: any): void {
|
||||
// Picking from the list hands over the account itself rather than text.
|
||||
if (value && typeof value === 'object') {
|
||||
this.pickSundry(value);
|
||||
return;
|
||||
}
|
||||
value = String(value ?? '');
|
||||
this.sundrySearch = value;
|
||||
if (value !== this.sundryLabel(this.chosenSundry)) {
|
||||
const exact = this.sundryAccounts.find(
|
||||
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
|
||||
);
|
||||
this.sundryGlAccountId = exact ? exact.id : null;
|
||||
}
|
||||
}
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
@@ -84,6 +141,12 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
scheme: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
|
||||
// and the clerk searches for it and picks it.
|
||||
this.organizationService.getDdsSundryAccounts().subscribe({
|
||||
next: (accounts: any) => (this.sundryAccounts = accounts || []),
|
||||
error: () => (this.sundryAccounts = [])
|
||||
});
|
||||
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
|
||||
next: (accounts: any) => {
|
||||
this.ownAccounts = accounts || [];
|
||||
@@ -120,17 +183,46 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
return Math.max(0, rate - reduction);
|
||||
}
|
||||
|
||||
/** Why the lock-in should be waived for this account - required when it is inside it. */
|
||||
lockinReason = '';
|
||||
|
||||
/**
|
||||
* The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it
|
||||
* the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason,
|
||||
* for a manager to approve - the scheme's lock-in itself is never changed to let one account through.
|
||||
*/
|
||||
get closableFrom(): Date | null {
|
||||
const months = Number(this.scheme?.prematureLockinMonths || 0);
|
||||
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
|
||||
if (months <= 0 || !opened || isNaN(opened.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const closable = new Date(opened);
|
||||
closable.setMonth(closable.getMonth() + months);
|
||||
return closable;
|
||||
}
|
||||
|
||||
get withinLockin(): boolean {
|
||||
const closable = this.closableFrom;
|
||||
return !!closable && closable.getTime() > Date.now();
|
||||
}
|
||||
|
||||
get canClose(): boolean {
|
||||
if (this.loading || !this.borneBy) {
|
||||
return false;
|
||||
}
|
||||
return this.destination === 'other' || !!this.selectedAccountNo;
|
||||
if (this.withinLockin && !this.lockinReason.trim()) {
|
||||
return false;
|
||||
}
|
||||
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close({
|
||||
shortfallBorneBy: this.borneBy,
|
||||
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {})
|
||||
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}),
|
||||
...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}),
|
||||
...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user