feat(dds): agent-first Party Master, account actions menu, FD-style statuses
- Party Master opens on agents (paged, from the server summary), then one agent's dashboard: accounts, maturity, money and collection cards, each with a View that filters the accounts table. The page title follows the view, and Back from an account returns to its agent. - DDS account page: an actions menu with Edit, Premature Closure, and Maturity Payout & Close. The manual Close Account buttons are gone, since payout and premature closure close the account themselves. - Statuses Active / Matured / Closed / Premature Closed are shown and filtered on every DDS screen through a shared dds-status helper. - Premature closure dialog redesigned: term progress, numbered steps, and a payout calculation for each choice using the server's formulas. - Agent & Slab Approval tab: slab approvals moved in beside agents, with toggle buttons and labels named for the action; richer slab review. - Batch processing: calculation runs are reported as applied, and an empty run is reported as nothing to do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ import { AlertService } from 'app/core/alert/alert.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
|
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
|
||||||
|
import { isDdsClosed } from '../dds-status';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily Transaction Entry component.
|
* Daily Transaction Entry component.
|
||||||
@@ -673,7 +674,7 @@ export class DailyTransactionEntryComponent implements OnInit {
|
|||||||
this.organizationService.getParties().subscribe({
|
this.organizationService.getParties().subscribe({
|
||||||
next: (parties: any) => {
|
next: (parties: any) => {
|
||||||
const all = Array.isArray(parties) ? parties : parties?.pageItems || [];
|
const all = Array.isArray(parties) ? parties : parties?.pageItems || [];
|
||||||
this.openParties = all.filter((party: any) => party.status === 'ACTIVE');
|
this.openParties = all.filter((party: any) => !isDdsClosed(party.status));
|
||||||
this.fillTransferParty('creditor');
|
this.fillTransferParty('creditor');
|
||||||
this.fillTransferParty('debit');
|
this.fillTransferParty('debit');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
|
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
|
||||||
<div class="dav-banner__text">
|
<div class="dav-banner__text">
|
||||||
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
|
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
|
||||||
>● {{ account.status }}</span
|
>● {{ statusLabel(account.status) }}</span
|
||||||
>
|
>
|
||||||
<h2 class="dav-banner__title">{{ account.subType }}</h2>
|
<h2 class="dav-banner__title">{{ account.subType }}</h2>
|
||||||
<div class="dav-banner__meta">
|
<div class="dav-banner__meta">
|
||||||
@@ -45,12 +45,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dav-banner__actions">
|
<div class="dav-banner__actions">
|
||||||
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
|
<button class="dav-icon-btn" (click)="back()" [matTooltip]="backLabel">
|
||||||
<fa-icon icon="arrow-left"></fa-icon>
|
<fa-icon icon="arrow-left"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
|
@if (isOpen) {
|
||||||
<fa-icon icon="pen"></fa-icon>
|
<button
|
||||||
|
class="dav-icon-btn"
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
matTooltip="More actions"
|
||||||
|
aria-label="More actions"
|
||||||
|
>
|
||||||
|
<fa-icon icon="ellipsis-vertical"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button mat-menu-item (click)="openInPartyMaster()">
|
||||||
|
<mat-icon><fa-icon icon="pen" size="sm"></fa-icon></mat-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
@if (canCloseEarly) {
|
||||||
|
<button mat-menu-item (click)="closeEarly()">
|
||||||
|
<mat-icon><fa-icon icon="times" size="sm"></fa-icon></mat-icon>
|
||||||
|
<span>Premature Closure</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (isMaturityDue) {
|
||||||
|
<button mat-menu-item (click)="payOut()">
|
||||||
|
<mat-icon><fa-icon icon="wallet" size="sm"></fa-icon></mat-icon>
|
||||||
|
<span>Maturity Payout & Close</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-menu>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,7 +97,7 @@
|
|||||||
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
|
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
|
||||||
<span>Nominees</span>
|
<span>Nominees</span>
|
||||||
</a>
|
</a>
|
||||||
@if (account.status === 'CLOSED') {
|
@if (!isOpen) {
|
||||||
<a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
|
<a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
|
||||||
<fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
|
<fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
|
||||||
<span>Closure</span>
|
<span>Closure</span>
|
||||||
@@ -582,7 +607,7 @@
|
|||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
<div class="detail-label">Status</div>
|
<div class="detail-label">Status</div>
|
||||||
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
|
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
|
||||||
{{ account.status }}
|
{{ statusLabel(account.status) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
|
|||||||
@@ -82,6 +82,18 @@
|
|||||||
border-color: rgb(226 232 240 / 40%);
|
border-color: rgb(226 232 240 / 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dav-pill--matured {
|
||||||
|
background: rgb(245 158 11 / 25%);
|
||||||
|
color: #fde68a;
|
||||||
|
border-color: rgb(253 230 138 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dav-pill--premature_closed {
|
||||||
|
background: rgb(239 68 68 / 25%);
|
||||||
|
color: #fecaca;
|
||||||
|
border-color: rgb(254 202 202 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
.dav-banner__title {
|
.dav-banner__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ import { catchError } from 'rxjs/operators';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
||||||
import { OrganizationService } from 'app/organization/organization.service';
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { PayoutDialogComponent } from '../../party-master/payout-dialog/payout-dialog.component';
|
||||||
|
import { ddsStatusLabel, isDdsClosed } from '../../dds-status';
|
||||||
|
import { PrematureCloseDialogComponent } from '../../party-master/premature-close-dialog/premature-close-dialog.component';
|
||||||
|
|
||||||
/** What each DDS transaction type is called on screen. */
|
/** What each DDS transaction type is called on screen. */
|
||||||
const TRANSACTION_LABELS: Record<string, string> = {
|
const TRANSACTION_LABELS: Record<string, string> = {
|
||||||
@@ -50,7 +56,7 @@ const TRANSACTION_LABELS: Record<string, string> = {
|
|||||||
MATURITY_PAYOUT: 'Maturity Payout',
|
MATURITY_PAYOUT: 'Maturity Payout',
|
||||||
PREMATURE_PAYOUT: 'Premature Payout',
|
PREMATURE_PAYOUT: 'Premature Payout',
|
||||||
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
|
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
|
||||||
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
|
PREMATURE_AGENT_TOPUP: 'Interest Funded by Agent',
|
||||||
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
|
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
|
||||||
TRANSFER: 'Transfer'
|
TRANSFER: 'Transfer'
|
||||||
};
|
};
|
||||||
@@ -107,6 +113,9 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
private organizationService = inject(OrganizationService);
|
private organizationService = inject(OrganizationService);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
private alertService = inject(AlertService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
account: any = null;
|
account: any = null;
|
||||||
@@ -318,7 +327,7 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
|
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
|
||||||
}).subscribe(({ account, transactions }) => {
|
}).subscribe(({ account, transactions }) => {
|
||||||
this.account = account;
|
this.account = account;
|
||||||
if (account?.status === 'CLOSED') {
|
if (isDdsClosed(account?.status)) {
|
||||||
this.organizationService.getPartyClosure(partyId).subscribe({
|
this.organizationService.getPartyClosure(partyId).subscribe({
|
||||||
next: (closure: any) => (this.closure = closure),
|
next: (closure: any) => (this.closure = closure),
|
||||||
error: () => (this.closure = null)
|
error: () => (this.closure = null)
|
||||||
@@ -412,7 +421,7 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
return (
|
return (
|
||||||
this.totalOf('INTEREST_POST') +
|
this.totalOf('INTEREST_POST') +
|
||||||
this.totalOf('OD_INTEREST_POST') +
|
this.totalOf('OD_INTEREST_POST') +
|
||||||
this.totalOf('PREMATURE_AGENT_TOP_UP') -
|
this.totalOf('PREMATURE_AGENT_TOPUP') -
|
||||||
recovered
|
recovered
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -521,6 +530,8 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly statusLabel = ddsStatusLabel;
|
||||||
|
|
||||||
label(type: string): string {
|
label(type: string): string {
|
||||||
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
|
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
@@ -529,16 +540,29 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
switch (this.account?.status) {
|
switch (this.account?.status) {
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
return 'status--active';
|
return 'status--active';
|
||||||
|
case 'MATURED':
|
||||||
|
return 'status--default';
|
||||||
case 'CLOSED':
|
case 'CLOSED':
|
||||||
|
case 'PREMATURE_CLOSED':
|
||||||
return 'status--closed';
|
return 'status--closed';
|
||||||
default:
|
default:
|
||||||
return 'status--default';
|
return 'status--default';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get backLabel(): string {
|
||||||
|
return this.route.snapshot.queryParamMap.has('agent') ? "Back to the agent's accounts" : 'Back to DDS Accounts';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opened from an agent's page in Party Master, Back returns to that agent; otherwise to the DDS Accounts list. */
|
||||||
back(): void {
|
back(): void {
|
||||||
|
const agent = this.route.snapshot.queryParamMap.get('agent');
|
||||||
|
if (agent) {
|
||||||
|
this.router.navigate(['/accounts/dds'], { queryParams: { agent } });
|
||||||
|
} else {
|
||||||
this.router.navigate(['/accounts/dds-accounts']);
|
this.router.navigate(['/accounts/dds-accounts']);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openInPartyMaster(): void {
|
openInPartyMaster(): void {
|
||||||
this.router.navigate([
|
this.router.navigate([
|
||||||
@@ -547,4 +571,127 @@ export class DdsAccountViewComponent implements OnInit {
|
|||||||
'edit'
|
'edit'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return !!this.account && !isDdsClosed(this.account.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closing before the term is up - only while the maturity date is ahead. A matured account is paid
|
||||||
|
* out and closed on its own terms, and the server refuses an early close on it.
|
||||||
|
*/
|
||||||
|
get canCloseEarly(): boolean {
|
||||||
|
const at = this.maturityAt;
|
||||||
|
return this.isOpen && this.account.status === 'ACTIVE' && !!at && at.getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get closeDateFields(): { closeDate: string; dateFormat: string; locale: string } {
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
return {
|
||||||
|
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
||||||
|
dateFormat,
|
||||||
|
locale: this.settingsService.language.code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The term is up: the maturity date has come, whether or not the nightly job has marked it MATURED. */
|
||||||
|
get isMaturityDue(): boolean {
|
||||||
|
if (!this.isOpen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.account.status === 'MATURED') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const at = this.maturityAt;
|
||||||
|
return !!at && at.getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get maturityAt(): Date | null {
|
||||||
|
const maturity = this.account?.maturityDate;
|
||||||
|
if (!maturity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const at = Array.isArray(maturity) ? new Date(maturity[0], maturity[1] - 1, maturity[2]) : new Date(maturity);
|
||||||
|
return isNaN(at.getTime()) ? null : at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a matured account out. The server closes it (status CLOSED) in the same step, as a fixed
|
||||||
|
* deposit is closed on its maturity payout - there is no separate close.
|
||||||
|
*/
|
||||||
|
payOut(): void {
|
||||||
|
if (!this.isMaturityDue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const account = this.account;
|
||||||
|
const dialogRef = this.dialog.open(PayoutDialogComponent, { data: account, width: '520px', autoFocus: false });
|
||||||
|
dialogRef.afterClosed().subscribe((payoutCommand: any) => {
|
||||||
|
if (!payoutCommand) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.organizationService.payOutParty(account.id, payoutCommand).subscribe((result: any) => {
|
||||||
|
const awaitingApproval = !!result?.commandId && result?.resourceId == null;
|
||||||
|
this.alertService.alert({
|
||||||
|
type: awaitingApproval ? 'Sent for Approval' : 'Paid Out & Closed',
|
||||||
|
message: awaitingApproval
|
||||||
|
? `Maturity payout of ${account.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
|
||||||
|
: `${account.partyCode} is paid out and closed.`
|
||||||
|
});
|
||||||
|
this.back();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closing early. The dialog asks whether the customer or the agent carries the cost and shows what
|
||||||
|
* the scheme charges for each; the lock-in and everything else is applied by the server.
|
||||||
|
*/
|
||||||
|
closeEarly(): void {
|
||||||
|
if (!this.canCloseEarly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const account = this.account;
|
||||||
|
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
|
||||||
|
data: {
|
||||||
|
partyCode: account.partyCode,
|
||||||
|
partyName: account.partyName,
|
||||||
|
subType: account.subType,
|
||||||
|
interestRate: account.interestRate,
|
||||||
|
maturityDate: account.maturityDate,
|
||||||
|
currentBalance: account.currentBalance,
|
||||||
|
openingDate: account.openingDate,
|
||||||
|
clientId: account.clientId,
|
||||||
|
agentCode: account.agentCode,
|
||||||
|
// For the payout calculation the dialog shows.
|
||||||
|
id: account.id,
|
||||||
|
agentId: account.agentId,
|
||||||
|
interestCalc: account.interestCalc,
|
||||||
|
collectedToDate: account.collectedToDate
|
||||||
|
},
|
||||||
|
width: '960px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
autoFocus: false
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe((closeCommand: any) => {
|
||||||
|
if (!closeCommand) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
|
||||||
|
const request = { ...closeCommand, ...this.closeDateFields };
|
||||||
|
(closeCommand.lockinOverrideReason
|
||||||
|
? this.organizationService.prematureCloseOverrideParty(account.id, request)
|
||||||
|
: this.organizationService.prematureCloseParty(account.id, 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 ${account.partyCode} is waiting for a manager's approval. The account stays open until then.`
|
||||||
|
: `${account.partyCode} is closed.`
|
||||||
|
});
|
||||||
|
this.back();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,22 +64,18 @@
|
|||||||
>
|
>
|
||||||
All <span class="dds-status-filter__count">{{ allCount }}</span>
|
All <span class="dds-status-filter__count">{{ allCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@for (filter of statusFilters; track filter.value) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="dds-status-filter__btn dds-status-filter__btn--active"
|
class="dds-status-filter__btn"
|
||||||
[class.is-on]="statusFilter === 'ACTIVE'"
|
[class.dds-status-filter__btn--active]="filter.value === 'ACTIVE'"
|
||||||
(click)="setStatusFilter('ACTIVE')"
|
[class.dds-status-filter__btn--closed]="filter.value === 'CLOSED' || filter.value === 'PREMATURE_CLOSED'"
|
||||||
|
[class.is-on]="statusFilter === filter.value"
|
||||||
|
(click)="setStatusFilter(filter.value)"
|
||||||
>
|
>
|
||||||
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
|
{{ filter.label }} <span class="dds-status-filter__count">{{ countOf(filter.value) }}</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="dds-status-filter__btn dds-status-filter__btn--closed"
|
|
||||||
[class.is-on]="statusFilter === 'CLOSED'"
|
|
||||||
(click)="setStatusFilter('CLOSED')"
|
|
||||||
>
|
|
||||||
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date Filters: by the day the account was opened -->
|
<!-- Date Filters: by the day the account was opened -->
|
||||||
@@ -179,7 +175,7 @@
|
|||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
|
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
|
||||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
|
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
|
||||||
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
|
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ statusLabel(row.status) }}</span>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
||||||
import { OrganizationService } from 'app/organization/organization.service';
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { DDS_STATUS_LABELS, ddsStatusLabel } from '../dds-status';
|
||||||
|
|
||||||
|
type DdsStatusFilter = 'ALL' | 'ACTIVE' | 'MATURED' | 'CLOSED' | 'PREMATURE_CLOSED';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
|
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
|
||||||
@@ -93,7 +96,7 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
|||||||
private matching: any[] = [];
|
private matching: any[] = [];
|
||||||
|
|
||||||
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
|
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
|
||||||
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
|
statusFilter: DdsStatusFilter = 'ALL';
|
||||||
/** The page on screen. */
|
/** The page on screen. */
|
||||||
rows: any[] = [];
|
rows: any[] = [];
|
||||||
|
|
||||||
@@ -113,19 +116,25 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
|||||||
return this.matching.length;
|
return this.matching.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeCount(): number {
|
/** How many accounts the search finds in each status - one button per status. */
|
||||||
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
|
countOf(status: string): number {
|
||||||
|
return this.beforeStatus.filter((account) => account.status === status).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get closedCount(): number {
|
readonly statusFilters: { value: DdsStatusFilter; label: string }[] = [
|
||||||
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
|
{ value: 'ACTIVE', label: DDS_STATUS_LABELS['ACTIVE'] },
|
||||||
}
|
{ value: 'MATURED', label: DDS_STATUS_LABELS['MATURED'] },
|
||||||
|
{ value: 'CLOSED', label: DDS_STATUS_LABELS['CLOSED'] },
|
||||||
|
{ value: 'PREMATURE_CLOSED', label: DDS_STATUS_LABELS['PREMATURE_CLOSED'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly statusLabel = ddsStatusLabel;
|
||||||
|
|
||||||
get allCount(): number {
|
get allCount(): number {
|
||||||
return this.beforeStatus.length;
|
return this.beforeStatus.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
|
setStatusFilter(status: DdsStatusFilter): void {
|
||||||
this.statusFilter = status;
|
this.statusFilter = status;
|
||||||
this.pageIndex = 0;
|
this.pageIndex = 0;
|
||||||
this.applyFilters();
|
this.applyFilters();
|
||||||
@@ -257,6 +266,7 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
|||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
return 'status--active';
|
return 'status--active';
|
||||||
case 'CLOSED':
|
case 'CLOSED':
|
||||||
|
case 'PREMATURE_CLOSED':
|
||||||
return 'status--closed';
|
return 'status--closed';
|
||||||
default:
|
default:
|
||||||
return 'status--default';
|
return 'status--default';
|
||||||
|
|||||||
+189
-2
@@ -19,7 +19,7 @@
|
|||||||
<span>Loading entry…</span>
|
<span>Loading entry…</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
|
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview || isSlabReview">
|
||||||
@if (isProductReview) {
|
@if (isProductReview) {
|
||||||
<!--
|
<!--
|
||||||
A DDS product request reads as the product it creates or changes: who and what at the top,
|
A DDS product request reads as the product it creates or changes: who and what at the top,
|
||||||
@@ -205,6 +205,190 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
} @else if (isSlabReview) {
|
||||||
|
<!--
|
||||||
|
A commission slab request reads as the slab: the volume band and the rate it pays, what an
|
||||||
|
agent earns at the band's start, the change for an edit as old → new, and the whole chart of
|
||||||
|
that type with this slab in place - so the checker sees it still gives one rate per volume.
|
||||||
|
-->
|
||||||
|
<section class="ddr-hero">
|
||||||
|
<div class="ddr-hero__icon">
|
||||||
|
<mat-icon>{{ isEdit ? 'edit_note' : 'stacked_bar_chart' }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="ddr-hero__main">
|
||||||
|
<div class="ddr-hero__eyebrow">
|
||||||
|
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New slab' }}</span>
|
||||||
|
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="ddr-hero__title">{{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }}</h2>
|
||||||
|
<div class="ddr-hero__code">
|
||||||
|
<span>{{ valueOf('slabType') || 'Commission' }} slab</span>
|
||||||
|
@if (entry.resourceId) {
|
||||||
|
<span class="ddr-dot"></span>
|
||||||
|
<span class="ddr-mono">#{{ entry.resourceId }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ddr-hero__meta">
|
||||||
|
<span class="ddr-meta">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
<span>
|
||||||
|
<b>{{ entry.makerName || entry.maker }}</b>
|
||||||
|
<small>{{ entry.maker }}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="ddr-meta">
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
<span>
|
||||||
|
<b>{{ formatDateTime(entry.madeOn) }}</b>
|
||||||
|
<small>Submitted</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
@if (entry.status !== 'PENDING' && entry.checker) {
|
||||||
|
<span class="ddr-meta">
|
||||||
|
<mat-icon>verified_user</mat-icon>
|
||||||
|
<span>
|
||||||
|
<b>{{ entry.checkerName || entry.checker }}</b>
|
||||||
|
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ddr-stats ddr-stats--three">
|
||||||
|
<div class="ddr-stat ddr-stat--accent">
|
||||||
|
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Commission</span>
|
||||||
|
<span class="ddr-stat__value">{{ valueOf('commissionPercent') || '—' }}</span>
|
||||||
|
<span class="ddr-stat__hint">of the month's collection</span>
|
||||||
|
</div>
|
||||||
|
<div class="ddr-stat">
|
||||||
|
<span class="ddr-stat__label"><mat-icon>straighten</mat-icon>Collection band</span>
|
||||||
|
<span class="ddr-stat__value ddr-stat__value--small"
|
||||||
|
>{{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }}</span
|
||||||
|
>
|
||||||
|
<span class="ddr-stat__hint">agent's collection volume</span>
|
||||||
|
</div>
|
||||||
|
<div class="ddr-stat">
|
||||||
|
<span class="ddr-stat__label"><mat-icon>calculate</mat-icon>Example</span>
|
||||||
|
<span class="ddr-stat__value ddr-stat__value--small">{{ slabExample || '—' }}</span>
|
||||||
|
<span class="ddr-stat__hint">commission at the band's start</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (compareFields) {
|
||||||
|
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
|
||||||
|
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
|
||||||
|
@if (changedCount) {
|
||||||
|
<span>
|
||||||
|
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - shown below
|
||||||
|
as current → proposed.
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span>This edit changes nothing - every term matches the slab as it stands.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (group of fieldGroups; track group.name) {
|
||||||
|
<section class="ddr-section">
|
||||||
|
<header class="ddr-section__head">
|
||||||
|
<span class="ddr-section__icon"
|
||||||
|
><mat-icon>{{ group.icon }}</mat-icon></span
|
||||||
|
>
|
||||||
|
<span class="ddr-section__title">{{ group.name }}</span>
|
||||||
|
@if (group.changed) {
|
||||||
|
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
<div class="ddr-grid">
|
||||||
|
@for (field of group.fields; track field.key) {
|
||||||
|
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
|
||||||
|
<span class="ddr-tile__label">
|
||||||
|
{{ field.label }}
|
||||||
|
@if (field.changed) {
|
||||||
|
<span class="ddr-tile__flag">Changed</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (field.changed) {
|
||||||
|
<span class="ddr-tile__diff">
|
||||||
|
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (slabChart.length) {
|
||||||
|
<section class="ddr-section">
|
||||||
|
<header class="ddr-section__head">
|
||||||
|
<span class="ddr-section__icon"><mat-icon>table_rows</mat-icon></span>
|
||||||
|
<span class="ddr-section__title">{{ valueOf('slabType') || 'Commission' }} slab chart</span>
|
||||||
|
<span class="ddr-slabs__note">{{
|
||||||
|
entry.status === 'PENDING' ? 'as it will read once approved' : 'as it reads now'
|
||||||
|
}}</span>
|
||||||
|
</header>
|
||||||
|
@if (slabOverlaps) {
|
||||||
|
<div class="ddr-banner ddr-banner--danger">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<span>
|
||||||
|
This slab's band overlaps
|
||||||
|
<b>{{ slabOverlaps }} other slab{{ slabOverlaps !== 1 ? 's' : '' }}</b> - a collection in the overlap
|
||||||
|
would match more than one rate.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="ddr-slabs">
|
||||||
|
<div class="ddr-slabs__row ddr-slabs__row--head">
|
||||||
|
<span>#</span>
|
||||||
|
<span>Collection from</span>
|
||||||
|
<span>Collection to</span>
|
||||||
|
<span class="ddr-slabs__num">Commission</span>
|
||||||
|
</div>
|
||||||
|
@for (slab of slabChart; track $index) {
|
||||||
|
<div
|
||||||
|
class="ddr-slabs__row"
|
||||||
|
[class.ddr-slabs__row--requested]="slab.requested"
|
||||||
|
[class.ddr-slabs__row--overlap]="slab.overlaps"
|
||||||
|
>
|
||||||
|
<span class="ddr-mono">
|
||||||
|
{{ slab.id ? slab.id : 'New' }}
|
||||||
|
@if (slab.requested) {
|
||||||
|
<span class="ddr-tile__flag">{{ isEdit ? 'Edited' : 'New' }}</span>
|
||||||
|
}
|
||||||
|
@if (slab.overlaps) {
|
||||||
|
<span class="ddr-slabs__flag">Overlaps</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
@if (slab.was && !same(slab.was.from, slab.from)) {
|
||||||
|
<span class="ddr-tile__old">{{ rupees(slab.was.from) }}</span>
|
||||||
|
}
|
||||||
|
{{ rupees(slab.from) }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
@if (slab.was && !same(slab.was.to, slab.to)) {
|
||||||
|
<span class="ddr-tile__old">{{ rupees(slab.was.to) }}</span>
|
||||||
|
}
|
||||||
|
{{ rupees(slab.to) }}
|
||||||
|
</span>
|
||||||
|
<span class="ddr-slabs__num">
|
||||||
|
@if (slab.was && !same(slab.was.rate, slab.rate)) {
|
||||||
|
<span class="ddr-tile__old">{{ percent(slab.was.rate) }}</span>
|
||||||
|
}
|
||||||
|
<b>{{ percent(slab.rate) }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
} @else if (isAgentReview) {
|
} @else if (isAgentReview) {
|
||||||
<!--
|
<!--
|
||||||
An agent request reads as the agent it opens or changes: who and when at the top, then the
|
An agent request reads as the agent it opens or changes: who and when at the top, then the
|
||||||
@@ -700,7 +884,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (entry.status === 'PENDING' && !done) {
|
@if (entry.status === 'PENDING' && !done) {
|
||||||
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
|
<div
|
||||||
|
class="dri-actions"
|
||||||
|
[class.ddr-actions]="isProductReview || isAgentReview || isPartyReview || isSlabReview"
|
||||||
|
>
|
||||||
@if (!confirmReject) {
|
@if (!confirmReject) {
|
||||||
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
|
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
|||||||
@@ -239,6 +239,89 @@ $shadow-sm:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ddr-stats--three {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
.ddr-stats,
|
||||||
|
.ddr-stats--three {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commission slab chart ────────────────────────────────────────────────────
|
||||||
|
.ddr-slabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 1.4fr 1.4fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-dark;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--head {
|
||||||
|
background: $bg-soft;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $text-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--requested {
|
||||||
|
background: $brand-light;
|
||||||
|
box-shadow: inset 3px 0 0 $brand-mid;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--overlap {
|
||||||
|
background: #fef2f2;
|
||||||
|
box-shadow: inset 3px 0 0 #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__num {
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__flag {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Edit banner ──────────────────────────────────────────────────────────────
|
// ── Edit banner ──────────────────────────────────────────────────────────────
|
||||||
.ddr-banner {
|
.ddr-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -262,6 +345,13 @@ $shadow-sm:
|
|||||||
border-color: $border;
|
border-color: $border;
|
||||||
color: $text-mid;
|
color: $text-mid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sections ─────────────────────────────────────────────────────────────────
|
// ── Sections ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+47
-1
@@ -104,6 +104,51 @@ export class DdsApprovalReviewComponent implements OnInit {
|
|||||||
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
|
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A commission slab request, laid out as the slab and the chart it joins rather than as a money movement. */
|
||||||
|
get isSlabReview(): boolean {
|
||||||
|
return this.entry?.entity === 'COMMISSIONSLAB' && this.fields.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every slab of the request's type, the requested one marked and any it overlaps flagged. */
|
||||||
|
get slabChart(): any[] {
|
||||||
|
return this.detail?.slabChart || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get slabOverlaps(): number {
|
||||||
|
return this.slabChart.filter((slab: any) => slab.overlaps).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an agent earns at the start of the band: "₹50,001 → ₹1,000.02". */
|
||||||
|
get slabExample(): string {
|
||||||
|
const slab = this.slabChart.find((each: any) => each.requested);
|
||||||
|
if (!slab || slab.from === null || slab.from === undefined || slab.rate === null || slab.rate === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const commission = (Number(slab.from) * Number(slab.rate)) / 100;
|
||||||
|
return `${this.rupees(slab.from)} → ${this.rupees(commission)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly RUPEES = new Intl.NumberFormat('en-IN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'INR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
rupees(value: any): string {
|
||||||
|
return value === null || value === undefined || value === ''
|
||||||
|
? '—'
|
||||||
|
: DdsApprovalReviewComponent.RUPEES.format(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
percent(value: any): string {
|
||||||
|
return value === null || value === undefined || value === '' ? '—' : `${Number(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
same(a: any, b: any): boolean {
|
||||||
|
return Number(a) === Number(b);
|
||||||
|
}
|
||||||
|
|
||||||
/** The nominees a DDS account request names. */
|
/** The nominees a DDS account request names. */
|
||||||
get nominees(): any[] {
|
get nominees(): any[] {
|
||||||
return this.detail?.nominees || [];
|
return this.detail?.nominees || [];
|
||||||
@@ -161,7 +206,8 @@ export class DdsApprovalReviewComponent implements OnInit {
|
|||||||
Agent: 'badge',
|
Agent: 'badge',
|
||||||
'Address & Contact': 'location_on',
|
'Address & Contact': 'location_on',
|
||||||
'Branch & Accounts': 'account_balance',
|
'Branch & Accounts': 'account_balance',
|
||||||
Balances: 'balance'
|
Balances: 'balance',
|
||||||
|
'Commission Slab': 'stacked_bar_chart'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The terms in the sections the server groups them into, in the order it sends them. */
|
/** The terms in the sections the server groups them into, in the order it sends them. */
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DDS account's statuses, as a fixed deposit's run:
|
||||||
|
*
|
||||||
|
* ACTIVE → MATURED (maturity date reached, set nightly) → CLOSED (paid out at maturity)
|
||||||
|
* ACTIVE → PREMATURE_CLOSED (broken before maturity)
|
||||||
|
*
|
||||||
|
* Payout and premature closure close the account themselves; nobody closes one by hand.
|
||||||
|
*/
|
||||||
|
export const DDS_STATUS_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: 'Active',
|
||||||
|
MATURED: 'Matured',
|
||||||
|
CLOSED: 'Closed',
|
||||||
|
PREMATURE_CLOSED: 'Premature Closed'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Closed either way - at maturity or before it. */
|
||||||
|
export function isDdsClosed(status: string | null | undefined): boolean {
|
||||||
|
return status === 'CLOSED' || status === 'PREMATURE_CLOSED';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ddsStatusLabel(status: string | null | undefined): string {
|
||||||
|
return DDS_STATUS_LABELS[status ?? ''] ?? status ?? '';
|
||||||
|
}
|
||||||
@@ -206,9 +206,7 @@
|
|||||||
<span class="pm-account-no">{{ accountNo }}</span>
|
<span class="pm-account-no">{{ accountNo }}</span>
|
||||||
<span class="pm-account-meta">
|
<span class="pm-account-meta">
|
||||||
{{ accountSchemeName }} ·
|
{{ accountSchemeName }} ·
|
||||||
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{
|
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{ statusLabel(partyStatus) }}</span>
|
||||||
partyStatus === 'ACTIVE' ? 'Active' : partyStatus
|
|
||||||
}}</span>
|
|
||||||
· {{ accountBalance | number: '1.2-2' }}
|
· {{ accountBalance | number: '1.2-2' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1141,35 +1139,6 @@
|
|||||||
<div class="pm-action-bar">
|
<div class="pm-action-bar">
|
||||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
<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)="cancel()">Cancel</button>
|
||||||
<!--
|
|
||||||
Closing before the term is up. Kept here beside the ordinary close rather than on the party
|
|
||||||
list: it cannot be undone and it changes what the customer is owed, so it belongs on the
|
|
||||||
screen where the officer has the account open in front of them, not one click from a row.
|
|
||||||
Hidden once the account has matured - that one is paid out and closed on its own terms, and
|
|
||||||
the server refuses this on it.
|
|
||||||
-->
|
|
||||||
@if (partyId && !isClosed && canCloseEarly) {
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="pm-early-btn"
|
|
||||||
matTooltip="Close this account before its term ends, settling the shortfall"
|
|
||||||
(click)="closeEarly()"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times" style="margin-right: 6px"></i> Close Early
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (partyId && !isClosed) {
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
color="warn"
|
|
||||||
matTooltip="Balance must be zero before closing"
|
|
||||||
(click)="closeAccount()"
|
|
||||||
>
|
|
||||||
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (selectedTabIndex > 0) {
|
@if (selectedTabIndex > 0) {
|
||||||
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
|
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||||
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>Previous
|
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>Previous
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
|
import { ddsStatusLabel, isDdsClosed } from '../../dds-status';
|
||||||
|
|
||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
|
|
||||||
|
|
||||||
/** Custom Directives */
|
/** Custom Directives */
|
||||||
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
|
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
|
||||||
@@ -58,9 +58,8 @@ import { RestrictInputDirective } from 'app/directives/restrict-input.directive'
|
|||||||
* not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or
|
* not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or
|
||||||
* written by collections and the batch job - none of them are typed here.
|
* written by collections and the batch job - none of them are typed here.
|
||||||
*
|
*
|
||||||
* "Close Account" (command=close) permanently closes the Party once its balance is zero - the
|
* Closing an account - at nil balance, or early - is done from the DDS account page's actions menu,
|
||||||
* backend rejects the command otherwise. A closed Party can no longer be edited or receive new
|
* not from this form. A closed Party can no longer be edited or receive new Party Tran collections.
|
||||||
* Party Tran collections.
|
|
||||||
*
|
*
|
||||||
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
|
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
|
||||||
* the same client (resolved by account number, since that's what an officer has to hand) -
|
* the same client (resolved by account number, since that's what an officer has to hand) -
|
||||||
@@ -296,8 +295,10 @@ export class CreatePartyComponent implements OnInit {
|
|||||||
agentOptions: any[] = [];
|
agentOptions: any[] = [];
|
||||||
private allAgents: any[] = [];
|
private allAgents: any[] = [];
|
||||||
|
|
||||||
|
readonly statusLabel = ddsStatusLabel;
|
||||||
|
|
||||||
get isClosed(): boolean {
|
get isClosed(): boolean {
|
||||||
return this.partyStatus === 'CLOSED';
|
return isDdsClosed(this.partyStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,7 +323,7 @@ export class CreatePartyComponent implements OnInit {
|
|||||||
|
|
||||||
/** An account already running: its scheme terms are locked. */
|
/** An account already running: its scheme terms are locked. */
|
||||||
get termsLocked(): boolean {
|
get termsLocked(): boolean {
|
||||||
return !!this.partyId && this.partyStatus === 'ACTIVE';
|
return !!this.partyId && !isDdsClosed(this.partyStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
|
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
|
||||||
@@ -1791,123 +1792,6 @@ export class CreatePartyComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAccount(): void {
|
|
||||||
if (!this.partyId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
|
||||||
const locale = this.settingsService.language.code;
|
|
||||||
this.organizationService
|
|
||||||
.closeParty(this.partyId, {
|
|
||||||
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
|
|
||||||
dateFormat,
|
|
||||||
locale
|
|
||||||
})
|
|
||||||
.subscribe(() => this.exit());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this account can still be closed before its term is up.
|
|
||||||
*
|
|
||||||
* Only while the maturity date is ahead: once it has passed the account is paid out and closed on
|
|
||||||
* its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not
|
|
||||||
* checked here - that period lives on the scheme, and a copy of the rule on this screen would drift
|
|
||||||
* from the one the server applies. An account inside its lock-in is offered the button and refused
|
|
||||||
* with the date it becomes closable, which tells the officer more than a missing button would.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* The client and agent as ids the API understands. Both fields are autocompletes, so each holds
|
|
||||||
* either the object picked from the list or the raw text typed into it - the dialog needs the
|
|
||||||
* client to look up which accounts can take the payout, and the agent's code only to name who is
|
|
||||||
* being charged.
|
|
||||||
*/
|
|
||||||
private get clientIdOfForm(): any {
|
|
||||||
const value = this.partyForm.get('clientId')?.value;
|
|
||||||
return value && typeof value === 'object' ? value.id : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get agentCodeOfForm(): string {
|
|
||||||
const value = this.partyForm.get('agentId')?.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 {
|
|
||||||
const maturity = this.partyForm.get('maturityDate')?.value;
|
|
||||||
if (!maturity) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const matured = maturity instanceof Date ? maturity : new Date(maturity);
|
|
||||||
return !isNaN(matured.getTime()) && matured.getTime() > Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the
|
|
||||||
* agent carries the cost - and shows what the scheme charges for each. Everything else, including
|
|
||||||
* the lock-in, is applied by the server.
|
|
||||||
*/
|
|
||||||
closeEarly(): void {
|
|
||||||
if (!this.partyId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
|
|
||||||
data: {
|
|
||||||
partyCode: this.partyForm.get('partyCode')?.value,
|
|
||||||
partyName: this.partyForm.get('partyName')?.value,
|
|
||||||
subType: this.partyForm.get('subType')?.value,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
width: '560px',
|
|
||||||
autoFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe((closeCommand: any) => {
|
|
||||||
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;
|
|
||||||
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.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ helpers
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,21 +9,29 @@
|
|||||||
<div class="pl-root">
|
<div class="pl-root">
|
||||||
<div class="pl-header">
|
<div class="pl-header">
|
||||||
<div class="pl-header-left">
|
<div class="pl-header-left">
|
||||||
<div class="pl-header-ic"><fa-icon icon="users"></fa-icon></div>
|
<div class="pl-header-ic"><fa-icon [icon]="selectedAgent ? 'user-tie' : 'users'"></fa-icon></div>
|
||||||
<div class="pl-header-text">
|
<div class="pl-header-text">
|
||||||
<h2>Party Master</h2>
|
<h2>{{ pageTitle }}</h2>
|
||||||
<p>The DDS accounts on file — each one a customer collected from daily.</p>
|
<p>{{ pageSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-header-right">
|
<div class="pl-header-right">
|
||||||
<div class="pl-count-chip" matTooltip="Total parties">
|
@if (selectedAgent) {
|
||||||
|
<div class="pl-count-chip" matTooltip="Accounts this agent collects for">
|
||||||
<fa-icon icon="users"></fa-icon>
|
<fa-icon icon="users"></fa-icon>
|
||||||
<strong>{{ totalCount }}</strong>
|
<strong>{{ selectedAgent.accounts }}</strong>
|
||||||
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
|
<span>{{ selectedAgent.accounts === 1 ? 'account' : 'accounts' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="pl-count-chip" matTooltip="Agents with DDS accounts">
|
||||||
|
<fa-icon icon="user-tie"></fa-icon>
|
||||||
|
<strong>{{ agentGroups.length }}</strong>
|
||||||
|
<span>{{ agentGroups.length === 1 ? 'agent' : 'agents' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||||
<fa-icon icon="plus"></fa-icon>
|
<fa-icon icon="plus"></fa-icon>
|
||||||
<span>Create Party</span>
|
<span>Open DDS Account</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,57 +61,432 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="pl-kpis">
|
<!--
|
||||||
<div class="pl-kpi pl-kpi--blue">
|
The page level shows only the counts - agents and accounts - so it reads at a glance. The figures
|
||||||
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
|
are worked out agent by agent, and an agent's own page shows them in full.
|
||||||
<div>
|
-->
|
||||||
<div class="pl-kpi-n">{{ activeCount | number }}</div>
|
@if (loaded && totalCount > 0) {
|
||||||
<div class="pl-kpi-l">Open Accounts</div>
|
@if (!selectedAgent) {
|
||||||
|
<div class="pm-dash pm-dash--six">
|
||||||
|
<div class="pm-kpi pm-kpi--slate">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="user-tie"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ agentGroups.length }}</b>
|
||||||
|
<small>Total agents</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-kpi pm-kpi--blue">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="users"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ book.accounts }}</b>
|
||||||
|
<small>Total DDS accounts</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-kpi pm-kpi--green">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="check-circle"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ book.open }}</b>
|
||||||
|
<small>Active accounts</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-kpi pm-kpi--amber">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ book.matured }}</b>
|
||||||
|
<small>Matured accounts</small>
|
||||||
|
<em>{{ book.maturedBalance | number: '1.2-2' }} to pay out</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-kpi pm-kpi--grey">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="lock"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ book.closed }}</b>
|
||||||
|
<small>Closed accounts</small>
|
||||||
|
<em>{{ book.prematureClosed }} premature · {{ book.closed - book.prematureClosed }} at maturity</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-kpi pm-kpi--teal">
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ book.balance | number: '1.2-2' }}</b>
|
||||||
|
<small>Total deposits held</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
} @else if (agentLoading) {
|
||||||
class="pl-kpi pl-kpi--purple"
|
<!-- The agent's accounts are on their way; their figures are worked out from them. -->
|
||||||
matTooltip="What the agents should collect between them on a full collection day"
|
<div class="pm-loading">
|
||||||
|
<span class="pm-loading__spin"></span>
|
||||||
|
Loading {{ selectedAgent.agentName }}'s accounts…
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!--
|
||||||
|
One agent in full, in three plain groups: their accounts, the money, and the collection. Every
|
||||||
|
figure is a View: clicking it lists, below, the accounts that make it up.
|
||||||
|
-->
|
||||||
|
<h4 class="pm-group">Accounts</h4>
|
||||||
|
<div class="pm-dash">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--blue pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'all1'"
|
||||||
|
(click)="showView('all', 'all1')"
|
||||||
>
|
>
|
||||||
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
<span class="pm-kpi__ic"><fa-icon icon="users"></fa-icon></span>
|
||||||
<div>
|
<span class="pm-kpi__body">
|
||||||
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
|
<b>{{ figures.accounts }}</b>
|
||||||
<div class="pl-kpi-l">Daily Collection Due</div>
|
<small>Total accounts</small>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
|
</button>
|
||||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
|
class="pm-kpi pm-kpi--green pm-kpi--click"
|
||||||
<div class="pl-kpi-l">Total Deposits Held</div>
|
[class.pm-kpi--on]="cardKey === 'active2'"
|
||||||
</div>
|
(click)="showView('active', 'active2')"
|
||||||
</div>
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="check-circle"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.open }}</b>
|
||||||
|
<small>Active</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--grey pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'closed3'"
|
||||||
|
(click)="showView('closed', 'closed3')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="lock"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.closed }}</b>
|
||||||
|
<small>Closed</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--slate pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'nominee4'"
|
||||||
|
(click)="showView('nominee', 'nominee4')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="layer-group"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.withNominee }}</b>
|
||||||
|
<small>With nominee</small>
|
||||||
|
<em>{{ figures.minors }} minor {{ figures.minors === 1 ? 'account' : 'accounts' }}</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="pm-group">Maturity</h4>
|
||||||
|
<div class="pm-dash">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--amber pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'matured21'"
|
||||||
|
(click)="showView('matured', 'matured21')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.matured }}</b>
|
||||||
|
<small>Matured</small>
|
||||||
|
<em>{{ figures.maturedBalance | number: '1.2-2' }} awaiting payout</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--indigo pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'soon22'"
|
||||||
|
(click)="showView('soon', 'soon22')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="hourglass-half"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.maturingSoon }}</b>
|
||||||
|
<small>Maturing in 30 days</small>
|
||||||
|
<em>nearest maturity first</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--green pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'closedMaturity23'"
|
||||||
|
(click)="showView('closedMaturity', 'closedMaturity23')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.closedAtMaturity }}</b>
|
||||||
|
<small>Closed at maturity</small>
|
||||||
|
<em>paid out and closed</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--red pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'premature24'"
|
||||||
|
(click)="showView('premature', 'premature24')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="times-circle"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.prematureClosed }}</b>
|
||||||
|
<small>Premature closed</small>
|
||||||
|
<em>closed before maturity</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="pm-group">Money</h4>
|
||||||
|
<div class="pm-dash">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--teal pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'balance5'"
|
||||||
|
(click)="showView('balance', 'balance5')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.balance | number: '1.2-2' }}</b>
|
||||||
|
<small>Balance held</small>
|
||||||
|
<em>what the accounts hold today</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--purple pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'active6'"
|
||||||
|
(click)="showView('active', 'active6')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="calendar-day"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.dailyDue | number: '1.2-2' }}</b>
|
||||||
|
<small>Daily collection</small>
|
||||||
|
<em>due each day from active accounts</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--amber pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'interest7'"
|
||||||
|
(click)="showView('interest', 'interest7')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="percentage"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.interestAccrued | number: '1.2-2' }}</b>
|
||||||
|
<small>Interest to post</small>
|
||||||
|
<em>calculated, not in the balance yet</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-kpi pm-kpi--indigo pm-kpi--click"
|
||||||
|
[class.pm-kpi--on]="cardKey === 'maturing8'"
|
||||||
|
(click)="showView('maturing', 'maturing8')"
|
||||||
|
>
|
||||||
|
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
<span class="pm-kpi__body">
|
||||||
|
<b>{{ figures.maturityValue | number: '1.2-2' }}</b>
|
||||||
|
<small>Maturity value</small>
|
||||||
|
<em>{{ figures.maturingSoon }} maturing in 30 days · {{ figures.matured }} matured</em>
|
||||||
|
</span>
|
||||||
|
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="pm-group">Collection</h4>
|
||||||
|
<div class="pm-collect">
|
||||||
|
<div class="pm-collect__nums">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-collect__stat"
|
||||||
|
[class.pm-collect__stat--on]="cardKey === 'active9'"
|
||||||
|
(click)="showView('active', 'active9')"
|
||||||
|
>
|
||||||
|
<small>Collected so far</small>
|
||||||
|
<b class="pm-collect__in">{{ figures.collected | number: '1.2-2' }}</b>
|
||||||
|
<span class="pm-collect__view">View</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-collect__stat"
|
||||||
|
[class.pm-collect__stat--on]="cardKey === 'active10'"
|
||||||
|
(click)="showView('active', 'active10')"
|
||||||
|
>
|
||||||
|
<small>Due till today</small>
|
||||||
|
<b>{{ figures.expected | number: '1.2-2' }}</b>
|
||||||
|
<span class="pm-collect__view">View</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-collect__stat"
|
||||||
|
[class.pm-collect__stat--on]="cardKey === 'behind11'"
|
||||||
|
(click)="showView('behind', 'behind11')"
|
||||||
|
>
|
||||||
|
<small>Pending</small>
|
||||||
|
<b [class.pm-collect__due]="figures.arrearsAmount > 0">{{ figures.arrearsAmount | number: '1.2-2' }}</b>
|
||||||
|
<span class="pm-collect__view">View</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-collect__stat"
|
||||||
|
[class.pm-collect__stat--on]="cardKey === 'behind12'"
|
||||||
|
(click)="showView('behind', 'behind12')"
|
||||||
|
>
|
||||||
|
<small>Accounts behind</small>
|
||||||
|
<b [class.pm-collect__due]="figures.inArrears > 0">{{ figures.inArrears }}</b>
|
||||||
|
<span class="pm-collect__view">View</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pm-collect__stat"
|
||||||
|
[class.pm-collect__stat--on]="cardKey === 'ahead13'"
|
||||||
|
(click)="showView('ahead', 'ahead13')"
|
||||||
|
>
|
||||||
|
<small>Paid ahead</small>
|
||||||
|
<b>{{ figures.inAdvance }}</b>
|
||||||
|
<span class="pm-collect__view">View</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pm-collect__bar"><span [style.width.%]="collectionRate"></span></div>
|
||||||
|
<div class="pm-collect__note">{{ collectionRate }}% of what was due till today has come in.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-products">
|
||||||
|
<span class="pm-products__label">By product</span>
|
||||||
|
@for (product of figures.byProduct; track product.name) {
|
||||||
|
<span class="pm-products__chip"
|
||||||
|
>{{ product.name }} <b>{{ product.count }}</b></span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="pl-card">
|
<div class="pl-card">
|
||||||
<div class="pl-toolbar">
|
<div class="pl-toolbar pm-toolbar">
|
||||||
|
@if (selectedAgent) {
|
||||||
|
<!-- Where the officer is: back to every agent, and the agent whose accounts are open. -->
|
||||||
|
<div class="pm-crumb">
|
||||||
|
<button type="button" class="pm-crumb__back" (click)="backToAgents()" matTooltip="Back to all agents">
|
||||||
|
<fa-icon icon="arrow-left"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pm-crumb__link" (click)="backToAgents()">All agents</button>
|
||||||
|
<fa-icon icon="chevron-right" class="pm-crumb__sep"></fa-icon>
|
||||||
|
<span class="pm-crumb__here">
|
||||||
|
<strong>{{ selectedAgent.agentName }}</strong>
|
||||||
|
<span class="pm-crumb__code">{{ selectedAgent.agentCode }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="pl-search">
|
<div class="pl-search">
|
||||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||||
|
<!-- Tracked so switching between agents and accounts starts the search over. -->
|
||||||
|
@if (selectedAgent) {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
(input)="applyFilter($any($event.target).value)"
|
(input)="applyFilter($any($event.target).value)"
|
||||||
placeholder="Search by code, name or agent…"
|
placeholder="Search {{ selectedAgent.agentName }}'s accounts by code or name…"
|
||||||
/>
|
/>
|
||||||
|
} @else {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="agentSearch"
|
||||||
|
(input)="applyFilter($any($event.target).value)"
|
||||||
|
placeholder="Search agents by code, name or branch…"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (totalCount === 0) {
|
@if (totalCount === 0) {
|
||||||
<div class="pl-empty">
|
<div class="pl-empty">
|
||||||
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
|
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
|
||||||
<h3>No parties yet</h3>
|
<h3>No DDS accounts yet</h3>
|
||||||
<p>Open one so an agent can start collecting daily deposits against it.</p>
|
<p>Open one so an agent can start collecting daily deposits against it.</p>
|
||||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||||
<fa-icon icon="plus"></fa-icon>
|
<fa-icon icon="plus"></fa-icon>
|
||||||
<span>Create Party</span>
|
<span>Open DDS Account</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (!selectedAgent) {
|
||||||
|
<!--
|
||||||
|
First level: the agents. Each card is what that agent collects for - accounts, the day's
|
||||||
|
collection due and the balance held - and opens their accounts on a click.
|
||||||
|
-->
|
||||||
|
<div class="pm-agents">
|
||||||
|
@for (group of pagedAgents; track group.agentCode) {
|
||||||
|
<button type="button" class="pm-agent" (click)="openAgent(group)">
|
||||||
|
<span class="pm-agent__avatar"><fa-icon icon="user-tie"></fa-icon></span>
|
||||||
|
<span class="pm-agent__who">
|
||||||
|
<strong>{{ group.agentName }}</strong>
|
||||||
|
<span class="pm-agent__code"
|
||||||
|
>{{ group.agentCode }}{{ group.officeName ? ' · ' + group.officeName : '' }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__stat">
|
||||||
|
<b>{{ group.open }}</b>
|
||||||
|
<small>open{{ group.closed ? ' · ' + group.closed + ' closed' : '' }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__stat">
|
||||||
|
<b>{{ group.dailyDue | number: '1.2-2' }}</b>
|
||||||
|
<small>daily due</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__stat">
|
||||||
|
<b>{{ group.collected | number: '1.2-2' }}</b>
|
||||||
|
<small>collected</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__stat">
|
||||||
|
<b>{{ group.balance | number: '1.2-2' }}</b>
|
||||||
|
<small>balance held</small>
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__flag" [class.pm-agent__flag--bad]="group.inArrears > 0">
|
||||||
|
@if (group.inArrears > 0) {
|
||||||
|
{{ group.inArrears }} behind
|
||||||
} @else {
|
} @else {
|
||||||
<div class="pl-table-wrap">
|
On track
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="pm-agent__go"><fa-icon icon="chevron-right"></fa-icon></span>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<div class="pl-no-match">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<span>No agents match your search.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (visibleAgents.length > 0) {
|
||||||
|
<mat-paginator
|
||||||
|
[length]="visibleAgents.length"
|
||||||
|
[pageIndex]="agentPageIndex"
|
||||||
|
[pageSize]="agentPageSize"
|
||||||
|
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||||
|
(page)="onAgentPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
></mat-paginator>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@if (cardView !== 'all') {
|
||||||
|
<div class="pm-viewing">
|
||||||
|
<fa-icon icon="filter"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Showing <b>{{ viewLabel }}</b> · {{ dataSource.data.length }}
|
||||||
|
{{ dataSource.data.length === 1 ? 'account' : 'accounts' }}</span
|
||||||
|
>
|
||||||
|
<button type="button" class="pm-viewing__clear" (click)="showView('all')">
|
||||||
|
Show all <fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="pl-table-wrap" #accountsTable>
|
||||||
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
|
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
|
||||||
<ng-container matColumnDef="partyCode">
|
<ng-container matColumnDef="partyCode">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
|
||||||
@@ -175,11 +558,22 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="schedule">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Schedule</th>
|
||||||
|
<td mat-cell *matCellDef="let p">
|
||||||
|
<span class="pm-sched" [ngClass]="'pm-sched--' + scheduleOf(p).tone">{{ scheduleOf(p).label }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
<td mat-cell *matCellDef="let p">
|
<td mat-cell *matCellDef="let p">
|
||||||
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
|
<span
|
||||||
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
|
class="pm-pill"
|
||||||
|
[class.pm-pill--off]="isClosed(p)"
|
||||||
|
[class.pm-pill--matured]="p.status === 'MATURED'"
|
||||||
|
>
|
||||||
|
{{ statusLabel(p.status) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -204,16 +598,29 @@
|
|||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
|
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
|
||||||
<td mat-cell *matCellDef="let p" class="num">
|
<td mat-cell *matCellDef="let p" class="num pm-actions">
|
||||||
|
<!-- The account itself - balance, transactions, passbook - without opening it for editing. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pl-action-btn pl-action-btn--view"
|
||||||
|
[routerLink]="['/accounts/dds-accounts', p.id]"
|
||||||
|
[queryParams]="{ agent: selectedAgent?.agentId }"
|
||||||
|
matTooltip="View this account, its transactions and passbook"
|
||||||
|
>
|
||||||
|
<fa-icon icon="eye"></fa-icon>
|
||||||
|
<span>View</span>
|
||||||
|
</button>
|
||||||
|
@if (!isClosed(p)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="pl-action-btn pl-action-btn--edit"
|
class="pl-action-btn pl-action-btn--edit"
|
||||||
[routerLink]="[p.id, 'edit']"
|
[routerLink]="[p.id, 'edit']"
|
||||||
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
|
matTooltip="Edit this account"
|
||||||
>
|
>
|
||||||
<fa-icon [icon]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
|
<fa-icon icon="edit"></fa-icon>
|
||||||
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@@ -222,18 +629,23 @@
|
|||||||
mat-row
|
mat-row
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
class="pl-row"
|
class="pl-row"
|
||||||
[class.pm-row--closed]="row.status === 'CLOSED'"
|
[class.pm-row--closed]="isClosed(row)"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@if (filteredCount === 0 && totalCount > 0) {
|
@if (dataSource.filteredData.length === 0 && totalCount > 0) {
|
||||||
<div class="pl-no-match">
|
<div class="pl-no-match">
|
||||||
<fa-icon icon="search"></fa-icon>
|
<fa-icon icon="search"></fa-icon>
|
||||||
<span>No parties match your filter.</span>
|
<span>No accounts of {{ selectedAgent.agentName }} match your search.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
|
<mat-paginator
|
||||||
|
#partyPaginator
|
||||||
|
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||||
|
[pageSize]="10"
|
||||||
|
showFirstLastButtons
|
||||||
|
></mat-paginator>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
color: rgb(0 0 0 / 52%);
|
color: rgb(0 0 0 / 52%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pm-pill--matured {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
/* The customer's real deposit account number, in the list. */
|
/* The customer's real deposit account number, in the list. */
|
||||||
.pm-list-account {
|
.pm-list-account {
|
||||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||||
@@ -131,3 +136,626 @@
|
|||||||
.pl-muted {
|
.pl-muted {
|
||||||
color: rgb(0 0 0 / 40%);
|
color: rgb(0 0 0 / 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent first: the agents, then one agent's accounts ───────────────────── */
|
||||||
|
.pm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-crumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--pl-accent-l);
|
||||||
|
border-color: var(--pl-accent);
|
||||||
|
color: var(--pl-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--pl-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__here {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-agents {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-agent {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(170px, 1.6fr) repeat(4, minmax(100px, 1fr)) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--pl-accent);
|
||||||
|
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
.pm-agent__go {
|
||||||
|
background: var(--pl-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--pl-accent-l);
|
||||||
|
color: var(--pl-accent);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__who {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0f172a;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__code {
|
||||||
|
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__go {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 760px) {
|
||||||
|
.pm-agent {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
|
||||||
|
.pm-agent__stat {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard: compact cards; on an agent's page each one opens its accounts ── */
|
||||||
|
.pm-dash {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&--five {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--six {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1100px) {
|
||||||
|
.pm-dash {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
&--five,
|
||||||
|
&--six {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 560px) {
|
||||||
|
.pm-dash,
|
||||||
|
.pm-dash--five,
|
||||||
|
.pm-dash--six {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-kpi {
|
||||||
|
--k: #2563eb;
|
||||||
|
--k-l: #eff6ff;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 3px solid var(--k);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||||
|
|
||||||
|
&__ic {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--k-l);
|
||||||
|
color: var(--k);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View sits in the top-right corner, clear of the figure and its note */
|
||||||
|
&__view {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--k-l);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--k);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition:
|
||||||
|
opacity 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--click {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 64px;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 16px rgb(15 23 42 / 10%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
.pm-kpi__view {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The card clicked: its own colour, nothing borrowed */
|
||||||
|
&--on {
|
||||||
|
background: var(--k-l);
|
||||||
|
box-shadow: 0 0 0 2px var(--k);
|
||||||
|
|
||||||
|
.pm-kpi__view {
|
||||||
|
background: var(--k);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
--k: #2563eb;
|
||||||
|
--k-l: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
--k: #7c3aed;
|
||||||
|
--k-l: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--teal {
|
||||||
|
--k: #0f766e;
|
||||||
|
--k-l: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
--k: #15803d;
|
||||||
|
--k-l: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
--k: #dc2626;
|
||||||
|
--k-l: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--grey {
|
||||||
|
--k: #64748b;
|
||||||
|
--k-l: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
--k: #b45309;
|
||||||
|
--k-l: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--indigo {
|
||||||
|
--k: #4338ca;
|
||||||
|
--k-l: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--slate {
|
||||||
|
--k: #334155;
|
||||||
|
--k-l: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-group {
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-collect {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||||
|
|
||||||
|
&__nums {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One figure, which lists its accounts on a click */
|
||||||
|
&__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Picked: the bank's green, the colour of the collection bar */
|
||||||
|
&--on {
|
||||||
|
border-color: #1f7a5c;
|
||||||
|
background: #e6f4f0;
|
||||||
|
box-shadow: 0 0 0 1px #1f7a5c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f7a5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__in {
|
||||||
|
color: #15803d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__due {
|
||||||
|
color: #b91c1c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 760px) {
|
||||||
|
.pm-collect__nums {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-products {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #334155;
|
||||||
|
|
||||||
|
b {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pl-accent-l);
|
||||||
|
color: var(--pl-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An agent card's on-track / behind flag */
|
||||||
|
.pm-agent__flag {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An account's standing against its daily schedule */
|
||||||
|
.pm-sched {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--good {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--off {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While an agent's accounts are fetched */
|
||||||
|
.pm-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
&__spin {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--pl-accent-l);
|
||||||
|
border-top-color: var(--pl-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pm-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pm-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Which accounts the table is showing, after a card was clicked */
|
||||||
|
.pm-viewing {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 20px 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #b7ddd0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e6f4f0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1f7a5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1f7a5c;
|
||||||
|
color: #1f7a5c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View and Edit side by side, never wrapped onto two lines */
|
||||||
|
.pm-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||||
import {
|
import {
|
||||||
MatTable,
|
MatTable,
|
||||||
@@ -28,19 +28,77 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { OrganizationService } from 'app/organization/organization.service';
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
|
import { ddsStatusLabel, isDdsClosed } from '../dds-status';
|
||||||
|
|
||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
|
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Party Master list.
|
* What a set of DDS accounts adds up to - the whole book, or one agent's share of it. Worked out the
|
||||||
|
* same way for both, so an agent's figures are the book's figures narrowed to that agent.
|
||||||
|
*/
|
||||||
|
interface BookFigures {
|
||||||
|
accounts: number;
|
||||||
|
open: number;
|
||||||
|
closed: number;
|
||||||
|
dailyDue: number;
|
||||||
|
balance: number;
|
||||||
|
collected: number;
|
||||||
|
expected: number;
|
||||||
|
/** Open accounts behind on their instalments, and what is owed on them. */
|
||||||
|
inArrears: number;
|
||||||
|
arrearsAmount: number;
|
||||||
|
/** Open accounts paid ahead of schedule. */
|
||||||
|
inAdvance: number;
|
||||||
|
/** Interest calculated but not yet posted into the balances. */
|
||||||
|
interestAccrued: number;
|
||||||
|
maturityValue: number;
|
||||||
|
/** Open accounts whose term ends within the next 30 days, and those already past it. */
|
||||||
|
maturingSoon: number;
|
||||||
|
matured: number;
|
||||||
|
/** What the matured, still-open accounts hold - what their payouts will hand over. */
|
||||||
|
maturedBalance: number;
|
||||||
|
/** Closed accounts, by how they closed: paid out at maturity, or broken before it. */
|
||||||
|
closedAtMaturity: number;
|
||||||
|
prematureClosed: number;
|
||||||
|
minors: number;
|
||||||
|
withNominee: number;
|
||||||
|
/** Accounts by DDS product, largest first. */
|
||||||
|
byProduct: { name: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One agent's line of the DDS book, as the server adds it up (/dds/parties/summary): the agent cards
|
||||||
|
* and the page's counts are built from these alone, without any account being sent.
|
||||||
|
*/
|
||||||
|
interface AgentGroup {
|
||||||
|
agentId: number | null;
|
||||||
|
agentCode: string;
|
||||||
|
agentName: string;
|
||||||
|
officeName: string;
|
||||||
|
accounts: number;
|
||||||
|
open: number;
|
||||||
|
closed: number;
|
||||||
|
matured: number;
|
||||||
|
maturedBalance: number;
|
||||||
|
prematureClosed: number;
|
||||||
|
dailyDue: number;
|
||||||
|
balance: number;
|
||||||
|
collected: number;
|
||||||
|
inArrears: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDS accounts, agent first.
|
||||||
*
|
*
|
||||||
* The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme
|
* The first level is the agents - the book's counts, then each agent with what they collect for - read
|
||||||
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
|
* as one summary line per agent, so it opens as fast with a hundred thousand accounts as with ten.
|
||||||
* shape as Agent Master and the product lists, and shares their stylesheet.
|
* Opening an agent fetches that agent's accounts only, and works out their full figures from them.
|
||||||
|
* A branch runs its DDS book agent by agent, so that is the order an officer looks for an account in.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-party-master',
|
selector: 'mifosx-party-master',
|
||||||
@@ -66,17 +124,110 @@ import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-d
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PartyMasterComponent implements OnInit {
|
export class PartyMasterComponent implements OnInit {
|
||||||
|
readonly isClosed = (p: any): boolean => isDdsClosed(p?.status);
|
||||||
|
readonly statusLabel = ddsStatusLabel;
|
||||||
|
|
||||||
private organizationService = inject(OrganizationService);
|
private organizationService = inject(OrganizationService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private static readonly DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Every agent with accounts, by agent code; the list's first level. */
|
||||||
|
agentGroups: AgentGroup[] = [];
|
||||||
|
/** The agent whose accounts are open below, or null while the agents are listed. */
|
||||||
|
selectedAgent: AgentGroup | null = null;
|
||||||
|
agentSearch = '';
|
||||||
|
agentPageIndex = 0;
|
||||||
|
agentPageSize = 10;
|
||||||
|
|
||||||
|
/** The whole book's counts, added up from the agents' summary lines. */
|
||||||
|
book = { accounts: 0, open: 0, closed: 0, matured: 0, maturedBalance: 0, prematureClosed: 0, balance: 0 };
|
||||||
|
|
||||||
|
/** The open agent's accounts, all of them; the table shows those the chosen card picks out. */
|
||||||
|
private agentParties: any[] = [];
|
||||||
|
|
||||||
|
/** Which accounts the table is showing; 'all' until a card is clicked. */
|
||||||
|
cardView = 'all';
|
||||||
|
/** The card that was clicked - two cards can list the same accounts, and only the one clicked lights up. */
|
||||||
|
cardKey = 'all';
|
||||||
|
|
||||||
|
private static readonly VIEW_LABELS: Record<string, string> = {
|
||||||
|
all: 'All accounts',
|
||||||
|
active: 'Active accounts',
|
||||||
|
closed: 'Closed accounts',
|
||||||
|
nominee: 'Accounts with a nominee',
|
||||||
|
balance: 'All accounts, largest balance first',
|
||||||
|
interest: 'Accounts with interest to post',
|
||||||
|
maturing: 'Active accounts, nearest maturity first',
|
||||||
|
matured: 'Matured accounts awaiting payout',
|
||||||
|
soon: 'Accounts maturing in the next 30 days',
|
||||||
|
closedMaturity: 'Accounts closed at maturity',
|
||||||
|
premature: 'Accounts closed prematurely',
|
||||||
|
behind: 'Accounts behind schedule',
|
||||||
|
ahead: 'Accounts paid ahead'
|
||||||
|
};
|
||||||
|
|
||||||
|
get viewLabel(): string {
|
||||||
|
return PartyMasterComponent.VIEW_LABELS[this.cardView] ?? 'All accounts';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts behind a figure: clicking a card lists, in the table below, exactly the accounts that
|
||||||
|
* make up its number - the same tests figuresOf counts them by - and scrolls the table into view.
|
||||||
|
*/
|
||||||
|
showView(view: string, key: string = view): void {
|
||||||
|
this.cardView = view;
|
||||||
|
this.cardKey = key;
|
||||||
|
const open = (p: any) => !isDdsClosed(p.status);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const soon = today.getTime() + 30 * PartyMasterComponent.DAY_MS;
|
||||||
|
const maturesAt = (p: any) => PartyMasterComponent.dateOf(p.maturityDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const tests: Record<string, (p: any) => boolean> = {
|
||||||
|
all: () => true,
|
||||||
|
balance: () => true,
|
||||||
|
active: open,
|
||||||
|
closed: (p) => !open(p),
|
||||||
|
nominee: (p) => open(p) && (p.nominees || []).length > 0,
|
||||||
|
interest: (p) => open(p) && Number(p.interestCalc || 0) > 0,
|
||||||
|
maturing: open,
|
||||||
|
matured: (p) => PartyMasterComponent.isMatured(p, today.getTime()),
|
||||||
|
soon: (p) => open(p) && maturesAt(p) > today.getTime() && maturesAt(p) <= soon,
|
||||||
|
closedMaturity: (p) => p.status === 'CLOSED',
|
||||||
|
premature: (p) => p.status === 'PREMATURE_CLOSED',
|
||||||
|
behind: (p) => open(p) && Number(p.arrearsDays || 0) > 0,
|
||||||
|
ahead: (p) => open(p) && Number(p.arrearsDays || 0) === 0 && Number(p.advanceDays || 0) > 0
|
||||||
|
};
|
||||||
|
let rows = this.agentParties.filter(tests[view] ?? tests['all']);
|
||||||
|
if (view === 'balance') {
|
||||||
|
rows = [...rows].sort((a, b) => Number(b.currentBalance || 0) - Number(a.currentBalance || 0));
|
||||||
|
} else if (view === 'maturing' || view === 'matured' || view === 'soon') {
|
||||||
|
rows = [...rows].sort((a, b) => maturesAt(a) - maturesAt(b));
|
||||||
|
}
|
||||||
|
this.dataSource.data = rows;
|
||||||
|
this.paginator?.firstPage();
|
||||||
|
if (view !== 'all') {
|
||||||
|
setTimeout(() => this.accountsTable?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewChild('accountsTable') accountsTable?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
/** The open agent's full figures, worked out from their accounts once those have come in. */
|
||||||
|
agentFigures: BookFigures | null = null;
|
||||||
|
agentLoading = false;
|
||||||
|
|
||||||
|
// The agent is known once one is opened, so the accounts table leaves out its Collected By column.
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'partyCode',
|
'partyCode',
|
||||||
'partyName',
|
'partyName',
|
||||||
'subType',
|
'subType',
|
||||||
'agentName',
|
|
||||||
'savingsAccountNo',
|
'savingsAccountNo',
|
||||||
'dailyDepositAmount',
|
'dailyDepositAmount',
|
||||||
'currentBalance',
|
'currentBalance',
|
||||||
|
'schedule',
|
||||||
'nominees',
|
'nominees',
|
||||||
'status',
|
'status',
|
||||||
'actions'
|
'actions'
|
||||||
@@ -114,15 +265,15 @@ export class PartyMasterComponent implements OnInit {
|
|||||||
dueForClosurePayable = 0;
|
dueForClosurePayable = 0;
|
||||||
|
|
||||||
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||||
parties: any[] = [];
|
loaded = false;
|
||||||
filteredCount = 0;
|
|
||||||
|
|
||||||
paginator: MatPaginator;
|
paginator: MatPaginator;
|
||||||
sort: MatSort;
|
sort: MatSort;
|
||||||
|
|
||||||
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
|
// The table sits inside an @if, so these only exist once an agent is open. Read statically they
|
||||||
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
|
// 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) {
|
// Picked by name: the agents have a paginator of their own, which must not page this table.
|
||||||
|
@ViewChild('partyPaginator') set matPaginator(paginator: MatPaginator) {
|
||||||
this.paginator = paginator;
|
this.paginator = paginator;
|
||||||
if (paginator) {
|
if (paginator) {
|
||||||
this.dataSource.paginator = paginator;
|
this.dataSource.paginator = paginator;
|
||||||
@@ -137,33 +288,75 @@ export class PartyMasterComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get totalCount(): number {
|
get totalCount(): number {
|
||||||
return this.parties.length;
|
return this.book.accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeCount(): number {
|
/** The open agent's full figures; the agent page shows them once the accounts are in. */
|
||||||
return this.parties.filter((party) => party.status !== 'CLOSED').length;
|
get figures(): BookFigures {
|
||||||
|
return this.agentFigures ?? PartyMasterComponent.NO_FIGURES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the whole book is worth today - every open account's running balance added up. */
|
private static readonly NO_FIGURES: BookFigures = PartyMasterComponent.figuresOf([]);
|
||||||
get totalCurrentBalance(): number {
|
|
||||||
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
|
/** The page's name for what is on screen. */
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.selectedAgent ? `${this.selectedAgent.agentName} — DDS Accounts` : 'DDS Agents & Accounts';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the agents should be collecting between them on a full collection day. */
|
get pageSubtitle(): string {
|
||||||
get totalDailyDue(): number {
|
if (this.selectedAgent) {
|
||||||
return this.parties
|
const a = this.selectedAgent;
|
||||||
.filter((party) => party.status !== 'CLOSED')
|
return `Agent ${a.agentCode}${a.officeName ? ' · ' + a.officeName : ''} · ${a.open} open ${
|
||||||
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
|
a.open === 1 ? 'account' : 'accounts'
|
||||||
|
} collected daily.`;
|
||||||
|
}
|
||||||
|
return 'Every agent and the daily-deposit accounts they collect for. Open an agent to see their accounts.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collected so far as a share of what was due by today, for the progress bar. */
|
||||||
|
get collectionRate(): number {
|
||||||
|
const f = this.figures;
|
||||||
|
return f.expected > 0 ? Math.min(100, Math.round((f.collected / f.expected) * 100)) : 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.organizationService.getParties().subscribe((parties: any[]) => {
|
this.organizationService.getDdsAgentSummary().subscribe((rows: any[]) => {
|
||||||
this.parties = parties || [];
|
this.agentGroups = (rows || []).map((row: any) => ({
|
||||||
this.dataSource = new MatTableDataSource(this.parties);
|
agentId: row.agentId ?? null,
|
||||||
this.dataSource.paginator = this.paginator;
|
agentCode: row.agentCode || '—',
|
||||||
this.dataSource.sort = this.sort;
|
agentName: row.agentName || 'No agent',
|
||||||
this.filteredCount = this.parties.length;
|
officeName: row.officeName || '',
|
||||||
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
|
accounts: Number(row.accounts || 0),
|
||||||
|
open: Number(row.open || 0),
|
||||||
|
closed: Number(row.closed || 0),
|
||||||
|
matured: Number(row.matured || 0),
|
||||||
|
maturedBalance: Number(row.maturedBalance || 0),
|
||||||
|
prematureClosed: Number(row.prematureClosed || 0),
|
||||||
|
dailyDue: Number(row.dailyDue || 0),
|
||||||
|
balance: Number(row.balance || 0),
|
||||||
|
collected: Number(row.collected || 0),
|
||||||
|
inArrears: Number(row.inArrears || 0)
|
||||||
|
}));
|
||||||
|
this.book = this.agentGroups.reduce(
|
||||||
|
(sum, group) => ({
|
||||||
|
accounts: sum.accounts + group.accounts,
|
||||||
|
open: sum.open + group.open,
|
||||||
|
closed: sum.closed + group.closed,
|
||||||
|
matured: sum.matured + group.matured,
|
||||||
|
maturedBalance: sum.maturedBalance + group.maturedBalance,
|
||||||
|
prematureClosed: sum.prematureClosed + group.prematureClosed,
|
||||||
|
balance: sum.balance + group.balance
|
||||||
|
}),
|
||||||
|
{ accounts: 0, open: 0, closed: 0, matured: 0, maturedBalance: 0, prematureClosed: 0, balance: 0 }
|
||||||
|
);
|
||||||
|
this.loaded = true;
|
||||||
|
|
||||||
|
// Coming back from an account (browser Back) reopens the agent it was opened under.
|
||||||
|
const agentParam = this.route.snapshot.queryParamMap.get('agent');
|
||||||
|
const reopen = agentParam ? this.agentGroups.find((group) => String(group.agentId) === agentParam) : null;
|
||||||
|
if (reopen) {
|
||||||
|
this.openAgent(reopen);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loadDueForClosure();
|
this.loadDueForClosure();
|
||||||
@@ -192,7 +385,197 @@ export class PartyMasterComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matured and not yet paid out: marked MATURED by the nightly job, or open with its maturity date
|
||||||
|
* come (today included) before the job has run - the same test the server's summary makes.
|
||||||
|
*/
|
||||||
|
private static isMatured(p: any, todayMs: number): boolean {
|
||||||
|
if (p.status === 'MATURED') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const maturity = PartyMasterComponent.dateOf(p.maturityDate);
|
||||||
|
return p.status === 'ACTIVE' && !!maturity && maturity.getTime() <= todayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a set of accounts adds up to; see BookFigures. */
|
||||||
|
private static figuresOf(parties: any[]): BookFigures {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const soon = today.getTime() + 30 * PartyMasterComponent.DAY_MS;
|
||||||
|
const products = new Map<string, number>();
|
||||||
|
const f: BookFigures = {
|
||||||
|
accounts: parties.length,
|
||||||
|
open: 0,
|
||||||
|
closed: 0,
|
||||||
|
dailyDue: 0,
|
||||||
|
balance: 0,
|
||||||
|
collected: 0,
|
||||||
|
expected: 0,
|
||||||
|
inArrears: 0,
|
||||||
|
arrearsAmount: 0,
|
||||||
|
inAdvance: 0,
|
||||||
|
interestAccrued: 0,
|
||||||
|
maturityValue: 0,
|
||||||
|
maturingSoon: 0,
|
||||||
|
matured: 0,
|
||||||
|
maturedBalance: 0,
|
||||||
|
closedAtMaturity: 0,
|
||||||
|
prematureClosed: 0,
|
||||||
|
minors: 0,
|
||||||
|
withNominee: 0,
|
||||||
|
byProduct: []
|
||||||
|
};
|
||||||
|
for (const p of parties) {
|
||||||
|
const product = p.subTypeDescription || p.subType || 'Other';
|
||||||
|
products.set(product, (products.get(product) || 0) + 1);
|
||||||
|
f.balance += Number(p.currentBalance || 0);
|
||||||
|
f.collected += Number(p.collectedToDate || 0);
|
||||||
|
if (isDdsClosed(p.status)) {
|
||||||
|
f.closed++;
|
||||||
|
if (p.status === 'PREMATURE_CLOSED') {
|
||||||
|
f.prematureClosed++;
|
||||||
|
} else {
|
||||||
|
f.closedAtMaturity++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
f.open++;
|
||||||
|
f.dailyDue += Number(p.dailyDepositAmount || 0);
|
||||||
|
f.expected += Number(p.expectedToDate || 0);
|
||||||
|
f.interestAccrued += Number(p.interestCalc || 0);
|
||||||
|
f.maturityValue += Number(p.maturityAmount || 0);
|
||||||
|
if (Number(p.arrearsDays || 0) > 0) {
|
||||||
|
f.inArrears++;
|
||||||
|
f.arrearsAmount += Number(p.recoverableAmount || 0);
|
||||||
|
} else if (Number(p.advanceDays || 0) > 0) {
|
||||||
|
f.inAdvance++;
|
||||||
|
}
|
||||||
|
const maturity = PartyMasterComponent.dateOf(p.maturityDate);
|
||||||
|
if (PartyMasterComponent.isMatured(p, today.getTime())) {
|
||||||
|
f.matured++;
|
||||||
|
f.maturedBalance += Number(p.currentBalance || 0);
|
||||||
|
} else if (maturity && maturity.getTime() <= soon) {
|
||||||
|
f.maturingSoon++;
|
||||||
|
}
|
||||||
|
if (String(p.adultMinor || '').toLowerCase() === 'minor') {
|
||||||
|
f.minors++;
|
||||||
|
}
|
||||||
|
if ((p.nominees || []).length > 0) {
|
||||||
|
f.withNominee++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.byProduct = [...products.entries()]
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
name,
|
||||||
|
count
|
||||||
|
]) => ({ name, count })
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date as the API sends it - [2026, 10, 27], "2026-10-27" or a timestamp - or null. */
|
||||||
|
private static dateOf(value: any): Date | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const d = Array.isArray(value) ? new Date(value[0], value[1] - 1, value[2]) : new Date(value);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents the search box leaves, by code, name or branch. */
|
||||||
|
get visibleAgents(): AgentGroup[] {
|
||||||
|
const term = this.agentSearch.trim().toLowerCase();
|
||||||
|
if (!term) {
|
||||||
|
return this.agentGroups;
|
||||||
|
}
|
||||||
|
return this.agentGroups.filter((group) =>
|
||||||
|
`${group.agentCode} ${group.agentName} ${group.officeName}`.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page of agents on show. */
|
||||||
|
get pagedAgents(): AgentGroup[] {
|
||||||
|
const start = this.agentPageIndex * this.agentPageSize;
|
||||||
|
return this.visibleAgents.slice(start, start + this.agentPageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAgentPage(event: PageEvent): void {
|
||||||
|
this.agentPageIndex = event.pageIndex;
|
||||||
|
this.agentPageSize = event.pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One agent's accounts, in the table below; the URL keeps the agent so Back returns to it. */
|
||||||
|
openAgent(group: AgentGroup): void {
|
||||||
|
this.selectedAgent = group;
|
||||||
|
this.agentFigures = null;
|
||||||
|
this.agentLoading = true;
|
||||||
|
this.dataSource.data = [];
|
||||||
|
this.dataSource.filter = '';
|
||||||
|
this.paginator?.firstPage();
|
||||||
|
// Only this agent's accounts are fetched - the book is never sent whole.
|
||||||
|
this.organizationService.getPartiesOfAgent(group.agentId ?? '').subscribe({
|
||||||
|
next: (parties: any[]) => {
|
||||||
|
// A different agent may have been opened while this one was loading.
|
||||||
|
if (this.selectedAgent !== group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.agentParties = parties || [];
|
||||||
|
this.cardView = 'all';
|
||||||
|
this.cardKey = 'all';
|
||||||
|
this.dataSource.data = this.agentParties;
|
||||||
|
this.agentFigures = PartyMasterComponent.figuresOf(this.agentParties);
|
||||||
|
this.agentLoading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (this.selectedAgent === group) {
|
||||||
|
this.agentFigures = PartyMasterComponent.figuresOf([]);
|
||||||
|
this.agentLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { agent: group.agentId },
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
backToAgents(): void {
|
||||||
|
this.selectedAgent = null;
|
||||||
|
this.agentParties = [];
|
||||||
|
this.cardView = 'all';
|
||||||
|
this.cardKey = 'all';
|
||||||
|
this.agentFigures = null;
|
||||||
|
this.agentLoading = false;
|
||||||
|
this.dataSource.data = [];
|
||||||
|
this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search box searches agents while agents are listed, and the open agent's accounts after. */
|
||||||
applyFilter(filterValue: string): void {
|
applyFilter(filterValue: string): void {
|
||||||
|
if (this.selectedAgent) {
|
||||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||||
|
} else {
|
||||||
|
this.agentSearch = filterValue;
|
||||||
|
this.agentPageIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an account stands against its daily schedule: behind, ahead or on track. */
|
||||||
|
scheduleOf(p: any): { label: string; tone: 'bad' | 'good' | 'ok' | 'off' } {
|
||||||
|
if (isDdsClosed(p.status)) {
|
||||||
|
return { label: 'Closed', tone: 'off' };
|
||||||
|
}
|
||||||
|
const behind = Number(p.arrearsDays || 0);
|
||||||
|
if (behind > 0) {
|
||||||
|
return { label: `${behind} day${behind === 1 ? '' : 's'} behind`, tone: 'bad' };
|
||||||
|
}
|
||||||
|
const ahead = Number(p.advanceDays || 0);
|
||||||
|
if (ahead > 0) {
|
||||||
|
return { label: `${ahead} day${ahead === 1 ? '' : 's'} ahead`, tone: 'good' };
|
||||||
|
}
|
||||||
|
return { label: 'On track', tone: 'ok' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export class PayoutDialogComponent implements OnInit {
|
|||||||
const interest =
|
const interest =
|
||||||
signed('INTEREST_POST') +
|
signed('INTEREST_POST') +
|
||||||
signed('OD_INTEREST_POST') +
|
signed('OD_INTEREST_POST') +
|
||||||
signed('PREMATURE_AGENT_TOP_UP') +
|
signed('PREMATURE_AGENT_TOPUP') +
|
||||||
signed('PREMATURE_INT_RECOVERY');
|
signed('PREMATURE_INT_RECOVERY');
|
||||||
const balance = Number(this.data.currentBalance || 0);
|
const balance = Number(this.data.currentBalance || 0);
|
||||||
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
|
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
|
||||||
|
|||||||
+241
-53
@@ -6,63 +6,116 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<h2 mat-dialog-title>Close {{ data.partyCode }} early</h2>
|
<!-- What is being closed, and what it becomes: an ACTIVE account turns PREMATURE_CLOSED. -->
|
||||||
|
<div class="pcd-head">
|
||||||
|
<span class="pcd-head__ic"><fa-icon icon="times-circle"></fa-icon></span>
|
||||||
|
<div class="pcd-head__text">
|
||||||
|
<h2 class="pcd-head__title">Premature Closure</h2>
|
||||||
|
<span class="pcd-head__sub">{{ data.partyCode }} · {{ data.partyName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pcd-head__status">
|
||||||
|
<span class="pcd-status pcd-status--active">Active</span>
|
||||||
|
<fa-icon icon="arrow-right"></fa-icon>
|
||||||
|
<span class="pcd-status pcd-status--premature">Premature Closed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-dialog-content>
|
<mat-dialog-content class="pcd-body">
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
<mat-progress-bar mode="indeterminate" class="pcd-loading"></mat-progress-bar>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The account as it stands, so the clerk is choosing against real figures rather than from memory.
|
The account as it stands, so the clerk is choosing against real figures rather than from memory.
|
||||||
The term still to run is the whole reason there is a charge at all.
|
The term still to run is the whole reason there is a charge at all.
|
||||||
-->
|
-->
|
||||||
<div class="pcd-summary">
|
<div class="pcd-tiles">
|
||||||
<div class="pcd-row">
|
<div class="pcd-tile">
|
||||||
<span>Customer</span>
|
<small>Balance</small>
|
||||||
<strong>{{ data.partyName }}</strong>
|
<b>₹{{ data.currentBalance | number: '1.2-2' }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="pcd-row">
|
<div class="pcd-tile">
|
||||||
<span>Balance</span>
|
<small>Scheme rate</small>
|
||||||
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
|
<b>{{ data.interestRate | number: '1.0-2' }}%</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="pcd-row">
|
<div class="pcd-tile">
|
||||||
<span>Matures on</span>
|
<small>Opened</small>
|
||||||
<strong>{{ data.maturityDate | dateFormat }}</strong>
|
<b>{{ data.openingDate | dateFormat }}</b>
|
||||||
</div>
|
</div>
|
||||||
<div class="pcd-row">
|
<div class="pcd-tile">
|
||||||
<span>Scheme rate</span>
|
<small>Matures on</small>
|
||||||
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
|
<b>{{ data.maturityDate | dateFormat }}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
|
@if (termDays > 0) {
|
||||||
|
<div class="pcd-term">
|
||||||
|
<div class="pcd-term__bar"><span [style.width.%]="termProgress"></span></div>
|
||||||
|
<div class="pcd-term__text">
|
||||||
|
<span
|
||||||
|
><b>{{ daysRun }}</b> of {{ termDays }} days run ({{ termProgress | number: '1.0-0' }}%)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><b>{{ daysLeft }}</b> {{ daysLeft === 1 ? 'day' : 'days' }} left to maturity</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (pendingInterest > 0) {
|
||||||
|
<div class="pcd-alert">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span
|
||||||
|
>₹{{ pendingInterest | number: '1.2-2' }} interest is calculated but not posted yet. Run
|
||||||
|
<b>Interest Posting</b> first — the closure is refused until it is in the balance.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pcd-grid">
|
||||||
|
<div class="pcd-main">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<h4 class="pcd-step"><span class="pcd-step__no">1</span> Who bears the cost of closing early?</h4>
|
||||||
|
|
||||||
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
|
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
|
||||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
|
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
|
||||||
<mat-radio-button value="CUSTOMER"></mat-radio-button>
|
<mat-radio-button value="CUSTOMER"></mat-radio-button>
|
||||||
<div class="pcd-choice-body">
|
<span class="pcd-choice__ic"><fa-icon icon="user"></fa-icon></span>
|
||||||
<div class="pcd-choice-title">Customer</div>
|
<div class="pcd-choice__body">
|
||||||
|
<div class="pcd-choice__title">Customer</div>
|
||||||
@if (scheme) {
|
@if (scheme) {
|
||||||
<div class="pcd-choice-detail">
|
<div class="pcd-choice__detail">
|
||||||
Interest recalculated at
|
Interest recalculated at
|
||||||
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
||||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
|
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
|
||||||
before the balance is paid out.
|
back before the balance is paid out.
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
|
<div class="pcd-choice__detail">Their interest is recalculated at the scheme's reduced rate.</div>
|
||||||
}
|
}
|
||||||
|
<div class="pcd-choice__gets">
|
||||||
|
Customer gets <b>₹{{ customerPayout | number: '1.2-2' }}</b>
|
||||||
|
@if (customerRecovery > 0) {
|
||||||
|
<span class="pcd-neg">(₹{{ customerRecovery | number: '1.2-2' }} interest taken back)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
|
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
|
||||||
<mat-radio-button value="AGENT"></mat-radio-button>
|
<mat-radio-button value="AGENT"></mat-radio-button>
|
||||||
<div class="pcd-choice-body">
|
<span class="pcd-choice__ic"><fa-icon icon="user-tie"></fa-icon></span>
|
||||||
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
|
<div class="pcd-choice__body">
|
||||||
<div class="pcd-choice-detail">
|
<div class="pcd-choice__title">Agent — {{ data.agentCode }}</div>
|
||||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
|
<div class="pcd-choice__detail">
|
||||||
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 class="pcd-choice__gets">
|
||||||
|
Customer gets <b>₹{{ agentPayout | number: '1.2-2' }}</b>
|
||||||
|
@if (agentTopUp > 0) {
|
||||||
|
<span class="pcd-pos">(+₹{{ agentTopUp | number: '1.2-2' }} paid by agent)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -73,20 +126,44 @@
|
|||||||
a clerk who did not know that would be surprised by it afterwards.
|
a clerk who did not know that would be surprised by it afterwards.
|
||||||
-->
|
-->
|
||||||
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
|
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
|
||||||
<p class="pcd-note">
|
<p class="pcd-info">
|
||||||
Either way, {{ data.agentCode }} is charged
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Either way, {{ data.agentCode }} is charged
|
||||||
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
|
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
|
||||||
of the commission owed to them — the round was not kept going.
|
of the commission owed to them — the round was not kept going.</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h4 class="pcd-question">Where should the balance go?</h4>
|
<!-- Step 2 -->
|
||||||
|
<h4 class="pcd-step"><span class="pcd-step__no">2</span> Where should the balance go?</h4>
|
||||||
|
|
||||||
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
|
<mat-radio-group class="pcd-choices pcd-choices--two" [(ngModel)]="destination">
|
||||||
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
|
<label
|
||||||
|
class="pcd-choice"
|
||||||
|
[class.pcd-choice--on]="destination === 'own'"
|
||||||
|
[class.pcd-choice--off]="ownAccounts.length === 0"
|
||||||
|
>
|
||||||
|
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0"></mat-radio-button>
|
||||||
|
<div class="pcd-choice__body">
|
||||||
|
<div class="pcd-choice__title">Customer's account</div>
|
||||||
|
<div class="pcd-choice__detail">
|
||||||
|
@if (ownAccounts.length > 0) {
|
||||||
Into another account of theirs at this bank
|
Into another account of theirs at this bank
|
||||||
</mat-radio-button>
|
} @else {
|
||||||
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
|
No other account here
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="pcd-choice" [class.pcd-choice--on]="destination === 'other'">
|
||||||
|
<mat-radio-button value="other"></mat-radio-button>
|
||||||
|
<div class="pcd-choice__body">
|
||||||
|
<div class="pcd-choice__title">Park in Sundry</div>
|
||||||
|
<div class="pcd-choice__detail">Held in their name until it is paid to them</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</mat-radio-group>
|
</mat-radio-group>
|
||||||
|
|
||||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||||
@@ -132,44 +209,155 @@
|
|||||||
</mat-form-field>
|
</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 the Sundry account chosen above, in their
|
|
||||||
name until it is paid to them.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
|
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.
|
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
|
||||||
-->
|
-->
|
||||||
@if (withinLockin) {
|
@if (withinLockin) {
|
||||||
|
<h4 class="pcd-step"><span class="pcd-step__no">3</span> Reason for closing within lock-in</h4>
|
||||||
<div class="pcd-lockin">
|
<div class="pcd-lockin">
|
||||||
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
|
<p class="pcd-lockin__title">
|
||||||
<p class="pcd-note">
|
<fa-icon icon="lock"></fa-icon> Within lock-in until {{ closableFrom | dateFormat }}
|
||||||
|
</p>
|
||||||
|
<p class="pcd-lockin__note">
|
||||||
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
|
{{ 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.
|
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
|
||||||
</p>
|
</p>
|
||||||
<mat-form-field appearance="outline" class="pcd-field">
|
<mat-form-field appearance="outline" class="pcd-field">
|
||||||
<mat-label>Reason for closing within lock-in</mat-label>
|
<mat-label>Reason</mat-label>
|
||||||
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
|
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
|
||||||
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
|
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<p class="pcd-warn">
|
<div class="pcd-warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>
|
||||||
@if (withinLockin) {
|
@if (withinLockin) {
|
||||||
Once approved, this closes the account. It cannot be reopened.
|
Once a manager approves, the balance is paid out and the account is closed automatically as
|
||||||
|
<b>Premature Closed</b>. It cannot be reopened.
|
||||||
} @else {
|
} @else {
|
||||||
This closes the account. It cannot be reopened.
|
The balance is paid out and the account is closed automatically as <b>Premature Closed</b>. It cannot be
|
||||||
|
reopened.
|
||||||
}
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The settlement worked out with the server's own formulas, on today's figures, so the clerk can
|
||||||
|
tell the customer what they will receive before choosing. The server applies the final figures.
|
||||||
|
-->
|
||||||
|
<aside class="pcd-calc">
|
||||||
|
<h4 class="pcd-calc__title"><fa-icon icon="calculator"></fa-icon> Payout calculation</h4>
|
||||||
|
|
||||||
|
<div class="pcd-calc__rows">
|
||||||
|
<div class="pcd-calc__row">
|
||||||
|
<span>Deposits collected</span>
|
||||||
|
<b>₹{{ deposits | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="pcd-calc__row">
|
||||||
|
<span>Interest credited so far</span>
|
||||||
|
<b>
|
||||||
|
@if (interestCredited === null) {
|
||||||
|
…
|
||||||
|
} @else {
|
||||||
|
₹{{ interestCredited | number: '1.2-2' }}
|
||||||
|
}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div class="pcd-calc__row pcd-calc__row--sum">
|
||||||
|
<span>Current balance</span>
|
||||||
|
<b>₹{{ balance | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (borneBy === 'CUSTOMER') {
|
||||||
|
<div class="pcd-calc__row pcd-neg">
|
||||||
|
<span
|
||||||
|
>Interest taken back
|
||||||
|
<small
|
||||||
|
>₹{{ interestCredited ?? 0 | number: '1.2-2' }} ×
|
||||||
|
{{ data.interestRate - reducedRate | number: '1.0-2' }}% ÷
|
||||||
|
{{ data.interestRate | number: '1.0-2' }}%</small
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<b>− ₹{{ customerRecovery | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
} @else if (borneBy === 'AGENT') {
|
||||||
|
<div class="pcd-calc__row pcd-pos">
|
||||||
|
<span
|
||||||
|
>Interest funded by {{ data.agentCode }}
|
||||||
|
<small
|
||||||
|
>₹{{ balance | number: '1.2-2' }} × {{ agentTopUpDays }} days ×
|
||||||
|
{{ data.interestRate | number: '1.0-2' }}% ÷ 365</small
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<b>+ ₹{{ agentTopUp | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcd-calc__total" [class.pcd-calc__total--empty]="payout === null">
|
||||||
|
<span>Customer receives</span>
|
||||||
|
@if (payout !== null) {
|
||||||
|
<b>₹{{ payout | number: '1.2-2' }}</b>
|
||||||
|
} @else {
|
||||||
|
<em>Choose who bears the cost</em>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="pcd-calc__to"><fa-icon icon="arrow-right"></fa-icon> {{ destinationLabel }}</div>
|
||||||
|
|
||||||
|
<div class="pcd-calc__agent">
|
||||||
|
<div class="pcd-calc__sub">Charged to agent {{ data.agentCode }}</div>
|
||||||
|
@if (borneBy === 'AGENT') {
|
||||||
|
<div class="pcd-calc__row">
|
||||||
|
<span>Interest top-up</span>
|
||||||
|
<b>₹{{ agentTopUp | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="pcd-calc__row">
|
||||||
|
<span
|
||||||
|
>Commission penalty
|
||||||
|
@if (scheme?.prematureAgentCommissionPenalty > 0) {
|
||||||
|
<small
|
||||||
|
>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% of ₹{{
|
||||||
|
agentCommission ?? 0 | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
owed</small
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<b>₹{{ commissionPenalty | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="pcd-calc__row pcd-calc__row--sum">
|
||||||
|
<span>Total charged to agent</span>
|
||||||
|
<b>₹{{ (borneBy === 'AGENT' ? agentTopUp : 0) + commissionPenalty | number: '1.2-2' }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="pcd-calc__foot">
|
||||||
|
Worked out on today's figures with the scheme's terms. The server applies the final amounts on the closing day.
|
||||||
</p>
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions class="pcd-actions">
|
||||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
<span class="pcd-actions__hint">
|
||||||
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
|
@if (!borneBy) {
|
||||||
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
|
Choose who bears the cost
|
||||||
|
} @else if (destination === 'own' && !selectedAccountNo) {
|
||||||
|
Pick the account to credit
|
||||||
|
} @else if (destination === 'other' && !sundryGlAccountId) {
|
||||||
|
Pick the Sundry GL account
|
||||||
|
} @else if (withinLockin && !lockinReason.trim()) {
|
||||||
|
Give the reason for closing within lock-in
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||||
|
<button mat-flat-button color="warn" type="button" class="pcd-submit" [disabled]="!canClose" (click)="close()">
|
||||||
|
<fa-icon [icon]="withinLockin ? 'paper-plane' : 'times-circle'"></fa-icon>
|
||||||
|
{{ withinLockin ? 'Send for Approval' : 'Close Prematurely' }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+475
-56
@@ -6,40 +6,183 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* The account as it stands, so the choice below is made against figures rather than from memory. */
|
/* What is being closed, and the status it moves to */
|
||||||
.pcd-summary {
|
.pcd-head {
|
||||||
margin: 4px 0 20px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgb(0 0 0 / 3%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
padding: 3px 0;
|
gap: 12px;
|
||||||
font-size: 0.85rem;
|
padding: 18px 24px;
|
||||||
|
background: linear-gradient(135deg, #1e3a8a, #4338ca);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
span {
|
&__ic {
|
||||||
color: rgb(0 0 0 / 60%);
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgb(255 255 255 / 75%);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(255 255 255 / 70%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-amount {
|
.pcd-status {
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border: 1px solid rgb(167 243 208 / 40%);
|
||||||
|
background: rgb(16 185 129 / 25%);
|
||||||
|
color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--premature {
|
||||||
|
border: 1px solid rgb(254 202 202 / 40%);
|
||||||
|
background: rgb(239 68 68 / 30%);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-body {
|
||||||
|
padding-top: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-loading {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The account as it stands, so the choice below is made against figures rather than from memory. */
|
||||||
|
.pcd-tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #0f172a;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pcd-question {
|
/* How much of the term has run - the reason closing now costs anything */
|
||||||
|
.pcd-term {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #4338ca, #6366f1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
margin: 20px 0 10px;
|
margin: 20px 0 10px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-choices {
|
.pcd-choices {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
|
|
||||||
|
&--two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -49,42 +192,69 @@
|
|||||||
.pcd-choice {
|
.pcd-choice {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
padding: 10px 12px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgb(0 0 0 / 12%);
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgb(0 0 0 / 2%);
|
border-color: #c7d2fe;
|
||||||
}
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-choice--on {
|
&--on,
|
||||||
border-color: #b45309;
|
&--on:hover {
|
||||||
background: rgb(180 83 9 / 5%);
|
border-color: #4338ca;
|
||||||
|
background: #eef2ff;
|
||||||
|
box-shadow: 0 0 0 1px #4338ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-choice-body {
|
&--off {
|
||||||
padding-top: 2px;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-choice-title {
|
&__ic {
|
||||||
font-weight: 600;
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on &__ic {
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-choice-detail {
|
&__detail {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgb(0 0 0 / 65%);
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-destinations {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-field {
|
.pcd-field {
|
||||||
@@ -92,41 +262,290 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd-note {
|
.pcd-info {
|
||||||
margin: 12px 0 0;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgb(0 0 0 / 65%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
|
fa-icon {
|
||||||
.pcd-warn {
|
margin-top: 2px;
|
||||||
margin: 18px 0 0;
|
}
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgb(185 28 28 / 25%);
|
|
||||||
background: rgb(185 28 28 / 6%);
|
|
||||||
color: #b91c1c;
|
|
||||||
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. */
|
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
|
||||||
.pcd-lockin {
|
.pcd-lockin {
|
||||||
margin-top: 18px;
|
|
||||||
padding: 12px 14px 4px;
|
padding: 12px 14px 4px;
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgb(180 83 9 / 30%);
|
border: 1px solid rgb(180 83 9 / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
background: rgb(245 158 11 / 8%);
|
background: rgb(245 158 11 / 8%);
|
||||||
}
|
|
||||||
|
|
||||||
.pcd-lockin-title {
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
|
||||||
|
.pcd-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 18px 0 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgb(185 28 28 / 25%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(185 28 28 / 6%);
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 16px !important;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-submit fa-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.po-hint-warn {
|
.po-hint-warn {
|
||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interest not yet posted: the closure will be refused, so it is said before anything is chosen */
|
||||||
|
.pcd-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid rgb(180 83 9 / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(245 158 11 / 10%);
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The choices on the left, the payout they lead to on the right */
|
||||||
|
.pcd-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-choice__gets {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #334155;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-neg {
|
||||||
|
color: #b91c1c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-pos {
|
||||||
|
color: #15803d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-calc {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sum {
|
||||||
|
margin-top: 2px;
|
||||||
|
border-top: 1px dashed #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #1e3a8a, #4338ca);
|
||||||
|
color: rgb(255 255 255 / 85%);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #fff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: rgb(255 255 255 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--empty {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
em {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__to {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__agent {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 760px) {
|
||||||
|
.pcd-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-calc {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.pcd-tiles {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-choices--two {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcd-head__status {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+153
-2
@@ -18,6 +18,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { OrganizationService } from 'app/organization/organization.service';
|
import { OrganizationService } from 'app/organization/organization.service';
|
||||||
@@ -56,6 +57,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
|
FaIconComponent,
|
||||||
DateFormatPipe
|
DateFormatPipe
|
||||||
],
|
],
|
||||||
templateUrl: './premature-close-dialog.component.html',
|
templateUrl: './premature-close-dialog.component.html',
|
||||||
@@ -174,6 +176,113 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: () => (this.scheme = null)
|
error: () => (this.scheme = null)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Interest already credited, which the customer's option scales back - summed the way the server
|
||||||
|
// sums it, from the account's own postings.
|
||||||
|
if (this.data.id) {
|
||||||
|
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
|
||||||
|
next: (txns: any) => {
|
||||||
|
const list: any[] = Array.isArray(txns) ? txns : txns?.pageItems || [];
|
||||||
|
this.interestCredited = list
|
||||||
|
.filter((t) => PrematureCloseDialogComponent.INTEREST_TYPES.includes(t.transactionType))
|
||||||
|
.reduce((sum, t) => sum + (t.direction === 'DEBIT' ? -1 : 1) * Number(t.amount || 0), 0);
|
||||||
|
},
|
||||||
|
error: () => (this.interestCredited = null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The commission the agent is owed now, which the scheme's penalty is a share of.
|
||||||
|
if (this.data.agentId) {
|
||||||
|
this.organizationService.getAgent(this.data.agentId).subscribe({
|
||||||
|
next: (agent: any) => (this.agentCommission = Number(agent?.commission || 0)),
|
||||||
|
error: () => (this.agentCommission = null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly INTEREST_TYPES = [
|
||||||
|
'INTEREST_POST',
|
||||||
|
'PREMATURE_INT_RECOVERY',
|
||||||
|
'PREMATURE_AGENT_TOPUP'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Interest credited to the account so far; null until read (or if it could not be). */
|
||||||
|
interestCredited: number | null = null;
|
||||||
|
/** What the agent is owed in commission today; null until read. */
|
||||||
|
agentCommission: number | null = null;
|
||||||
|
|
||||||
|
private static round2(value: number): number {
|
||||||
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
get balance(): number {
|
||||||
|
return Number(this.data.currentBalance || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interest calculated but not posted - the server refuses to close until it is posted. */
|
||||||
|
get pendingInterest(): number {
|
||||||
|
return Number(this.data.interestCalc || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the customer paid in: the balance less the interest credited on it. */
|
||||||
|
get deposits(): number {
|
||||||
|
const collected = Number(this.data.collectedToDate || 0);
|
||||||
|
return collected > 0 ? collected : Math.max(0, this.balance - (this.interestCredited ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer bears it: credited interest scaled by the share of the rate given up. */
|
||||||
|
get customerRecovery(): number {
|
||||||
|
const rate = Number(this.data.interestRate || 0);
|
||||||
|
const reduction = Number(this.scheme?.prematureInterestReduction || 0);
|
||||||
|
const credited = this.interestCredited ?? 0;
|
||||||
|
if (rate <= 0 || reduction <= 0 || credited <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return PrematureCloseDialogComponent.round2((credited * Math.min(reduction, rate)) / rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
get customerPayout(): number {
|
||||||
|
return PrematureCloseDialogComponent.round2(this.balance - this.customerRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days from today to the month's end, today counted - the days the agent funds. */
|
||||||
|
get agentTopUpDays(): number {
|
||||||
|
const today = new Date();
|
||||||
|
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
|
||||||
|
return lastDay - today.getDate() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent bears it: interest on the balance for the rest of this month, at the scheme rate. */
|
||||||
|
get agentTopUp(): number {
|
||||||
|
const rate = Number(this.data.interestRate || 0);
|
||||||
|
if (rate <= 0 || this.balance <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return PrematureCloseDialogComponent.round2((this.balance * this.agentTopUpDays * rate) / 36500);
|
||||||
|
}
|
||||||
|
|
||||||
|
get agentPayout(): number {
|
||||||
|
return PrematureCloseDialogComponent.round2(this.balance + this.agentTopUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The share of the agent's commission taken whichever way it is settled. */
|
||||||
|
get commissionPenalty(): number {
|
||||||
|
const rate = Number(this.scheme?.prematureAgentCommissionPenalty || 0);
|
||||||
|
if (rate <= 0 || !this.agentCommission) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return PrematureCloseDialogComponent.round2((this.agentCommission * rate) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the customer receives on the option picked, or null before one is picked. */
|
||||||
|
get payout(): number | null {
|
||||||
|
return this.borneBy === 'CUSTOMER' ? this.customerPayout : this.borneBy === 'AGENT' ? this.agentPayout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get destinationLabel(): string {
|
||||||
|
if (this.destination === 'own') {
|
||||||
|
return this.selectedAccountNo ? `Account ${this.selectedAccountNo}` : 'Customer account (not picked)';
|
||||||
|
}
|
||||||
|
return this.chosenSundry ? `Sundry ${this.sundryLabel(this.chosenSundry)}` : 'Sundry GL (not picked)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The rate the customer would be left on if they carry it. Never below nothing. */
|
/** The rate the customer would be left on if they carry it. Never below nothing. */
|
||||||
@@ -183,6 +292,48 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
return Math.max(0, rate - reduction);
|
return Math.max(0, rate - reduction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */
|
||||||
|
private static toDate(value: any): Date | null {
|
||||||
|
const date =
|
||||||
|
Array.isArray(value) && value.length >= 3
|
||||||
|
? new Date(value[0], value[1] - 1, value[2])
|
||||||
|
: value
|
||||||
|
? new Date(value)
|
||||||
|
: null;
|
||||||
|
return date && !isNaN(date.getTime()) ? date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** The whole term in days, opening to maturity; 0 when either date is missing. */
|
||||||
|
get termDays(): number {
|
||||||
|
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
|
||||||
|
const matures = PrematureCloseDialogComponent.toDate(this.data.maturityDate);
|
||||||
|
return opened && matures
|
||||||
|
? Math.max(0, Math.round((matures.getTime() - opened.getTime()) / PrematureCloseDialogComponent.DAY_MS))
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days of the term already run, today counted. */
|
||||||
|
get daysRun(): number {
|
||||||
|
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
|
||||||
|
if (!opened) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const run = Math.round((today.getTime() - opened.getTime()) / PrematureCloseDialogComponent.DAY_MS) + 1;
|
||||||
|
return Math.min(this.termDays, Math.max(0, run));
|
||||||
|
}
|
||||||
|
|
||||||
|
get daysLeft(): number {
|
||||||
|
return Math.max(0, this.termDays - this.daysRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
get termProgress(): number {
|
||||||
|
return this.termDays > 0 ? (this.daysRun / this.termDays) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Why the lock-in should be waived for this account - required when it is inside it. */
|
/** Why the lock-in should be waived for this account - required when it is inside it. */
|
||||||
lockinReason = '';
|
lockinReason = '';
|
||||||
|
|
||||||
@@ -193,8 +344,8 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
get closableFrom(): Date | null {
|
get closableFrom(): Date | null {
|
||||||
const months = Number(this.scheme?.prematureLockinMonths || 0);
|
const months = Number(this.scheme?.prematureLockinMonths || 0);
|
||||||
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
|
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
|
||||||
if (months <= 0 || !opened || isNaN(opened.getTime())) {
|
if (months <= 0 || !opened) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const closable = new Date(opened);
|
const closable = new Date(opened);
|
||||||
|
|||||||
@@ -170,11 +170,11 @@
|
|||||||
@if (isPostingJob) {
|
@if (isPostingJob) {
|
||||||
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
|
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
|
||||||
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
|
customer's balance, or out to the agent. <strong>Generate</strong> lists it first; nothing moves until you
|
||||||
press <strong>Post</strong>.
|
press <strong>Post</strong>. <strong>Post goes to a checker for approval</strong> before anything moves.
|
||||||
} @else {
|
} @else {
|
||||||
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
|
<strong>{{ jobTypeLabel }}</strong> works out what has been earned since the last run and records it against
|
||||||
each account. It does not become spendable until the matching Posting job runs.
|
each account right away - there is nothing to post or approve. It does not become spendable until the
|
||||||
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
|
matching Posting job runs.
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,9 +191,9 @@
|
|||||||
(click)="generate()"
|
(click)="generate()"
|
||||||
>
|
>
|
||||||
@if (generating) {
|
@if (generating) {
|
||||||
<i class="fas fa-circle-notch fa-spin"></i> Generating…
|
<i class="fas fa-circle-notch fa-spin"></i> {{ isCalculationJob ? 'Calculating…' : 'Generating…' }}
|
||||||
} @else {
|
} @else {
|
||||||
<i class="fas fa-calculator"></i> Generate
|
<i class="fas fa-calculator"></i> {{ isCalculationJob ? 'Calculate' : 'Generate' }}
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,7 +374,10 @@
|
|||||||
<td>
|
<td>
|
||||||
@switch (run.status) {
|
@switch (run.status) {
|
||||||
@case ('POSTED') {
|
@case ('POSTED') {
|
||||||
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
|
<span class="bp-status bp-status--posted"
|
||||||
|
><i class="fas fa-check-circle"></i>
|
||||||
|
{{ run.jobType.endsWith('_CALCULATION') ? 'Calculated' : 'Posted' }}</span
|
||||||
|
>
|
||||||
}
|
}
|
||||||
@case ('POSTING') {
|
@case ('POSTING') {
|
||||||
<span class="bp-status bp-status--running">
|
<span class="bp-status bp-status--running">
|
||||||
|
|||||||
@@ -382,6 +382,11 @@ export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
|
|||||||
return this.jobType.startsWith('COMMISSION');
|
return this.jobType.startsWith('COMMISSION');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Interest and commission calculations: applied as they are generated, with no Post and no checker. */
|
||||||
|
get isCalculationJob(): boolean {
|
||||||
|
return this.jobType.endsWith('_CALCULATION');
|
||||||
|
}
|
||||||
|
|
||||||
get isPostingJob(): boolean {
|
get isPostingJob(): boolean {
|
||||||
return this.jobType.endsWith('_POSTING');
|
return this.jobType.endsWith('_POSTING');
|
||||||
}
|
}
|
||||||
@@ -475,13 +480,29 @@ export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
|
|||||||
this.organizationService.generateDdsBatchRun(payload).subscribe({
|
this.organizationService.generateDdsBatchRun(payload).subscribe({
|
||||||
error: () => (this.generating = false),
|
error: () => (this.generating = false),
|
||||||
next: (result: any) => {
|
next: (result: any) => {
|
||||||
|
// Nothing to calculate or post - already up to date, or no activity - so the server saved no run.
|
||||||
|
if (!result?.resourceId) {
|
||||||
|
this.generating = false;
|
||||||
|
this.alertService.alert({
|
||||||
|
type: 'Information',
|
||||||
|
message: `${this.jobTypeLabel}: nothing new to ${this.isCalculationJob ? 'calculate' : 'post'} - it is already up to date for this date, or there was no activity.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.organizationService.getDdsBatchRun(result.resourceId).subscribe({
|
this.organizationService.getDdsBatchRun(result.resourceId).subscribe({
|
||||||
error: () => (this.generating = false),
|
error: () => (this.generating = false),
|
||||||
next: (batchRun: any) => {
|
next: (batchRun: any) => {
|
||||||
this.generating = false;
|
this.generating = false;
|
||||||
this.loadRecentRuns();
|
this.loadRecentRuns();
|
||||||
// The run is checked on a page of its own - agent by agent, with Post and Discard beside the
|
if (result?.changes?.calculated) {
|
||||||
// figures - so this page does not grow a second screen's worth below the form.
|
const entries = batchRun.rows?.length ?? batchRun.totalRows;
|
||||||
|
this.alertService.alert({
|
||||||
|
type: 'Success',
|
||||||
|
message: `${this.jobTypeLabel}: ${entries} ${entries === 1 ? 'entry' : 'entries'}, ₹ ${this.money(batchRun.totalAmount)} calculated and recorded. Nothing is paid out until the Posting job.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The run is shown on a page of its own - agent by agent, with Post and Discard beside the
|
||||||
|
// figures for a Posting run, read-only for a calculation that has already been applied.
|
||||||
this.router.navigate([
|
this.router.navigate([
|
||||||
'/organization/dds-batch-processing/run',
|
'/organization/dds-batch-processing/run',
|
||||||
batchRun.id
|
batchRun.id
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export class DdsBatchRunPreviewComponent implements OnInit {
|
|||||||
get statusText(): string {
|
get statusText(): string {
|
||||||
switch (this.generatedRun?.status) {
|
switch (this.generatedRun?.status) {
|
||||||
case 'POSTED':
|
case 'POSTED':
|
||||||
return 'Posted';
|
return this.jobType.endsWith('_CALCULATION') ? 'Calculated' : 'Posted';
|
||||||
case 'POSTING':
|
case 'POSTING':
|
||||||
return 'Running';
|
return 'Running';
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
@@ -331,7 +331,7 @@ export class DdsBatchRunPreviewComponent implements OnInit {
|
|||||||
(this.isPostingJob
|
(this.isPostingJob
|
||||||
? 'This credits the customers or pays the agents, and appears on the ledger. '
|
? 'This credits the customers or pays the agents, and appears on the ledger. '
|
||||||
: 'This records what has been earned against each account. ') +
|
: 'This records what has been earned against each account. ') +
|
||||||
'It cannot be undone from this screen.'
|
'It is sent to a checker for approval first. It cannot be undone from this screen.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
|
|||||||
@@ -272,6 +272,11 @@ export class OrganizationService {
|
|||||||
return this.http.get('/dds/parties');
|
return this.http.get('/dds/parties');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One line per agent - accounts, collection due, balance, collected, how many behind - added up by the server. */
|
||||||
|
getDdsAgentSummary(): Observable<any> {
|
||||||
|
return this.http.get('/dds/parties/summary');
|
||||||
|
}
|
||||||
|
|
||||||
/** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */
|
/** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */
|
||||||
getNextPartyCode(): Observable<any> {
|
getNextPartyCode(): Observable<any> {
|
||||||
return this.http.get('/dds/parties/nextcode');
|
return this.http.get('/dds/parties/nextcode');
|
||||||
|
|||||||
+115
-22
@@ -7,19 +7,51 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="psp-tab-content">
|
<div class="psp-tab-content">
|
||||||
@if (dataSource.data.length > 0) {
|
<!-- Agents or commission slabs, one at a time; agents first, as most requests are theirs. -->
|
||||||
<div class="psp-action-bar">
|
<div class="aa-bar">
|
||||||
<mat-form-field appearance="outline" class="psp-search-field">
|
<div class="aa-switch" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="aa-switch__btn"
|
||||||
|
[class.aa-switch__btn--active]="listView === 'agents'"
|
||||||
|
[attr.aria-selected]="listView === 'agents'"
|
||||||
|
(click)="show('agents')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="user-tie"></fa-icon>
|
||||||
|
Agents
|
||||||
|
<span class="aa-switch__count">{{ dataSource.data.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="aa-switch__btn"
|
||||||
|
[class.aa-switch__btn--active]="listView === 'slabs'"
|
||||||
|
[attr.aria-selected]="listView === 'slabs'"
|
||||||
|
(click)="show('slabs')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="layer-group"></fa-icon>
|
||||||
|
Commission Slabs
|
||||||
|
<span class="aa-switch__count">{{ slabSource.data.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="psp-search-field aa-search">
|
||||||
<mat-icon matPrefix>search</mat-icon>
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
<input
|
<input
|
||||||
#filterInput
|
#filterInput
|
||||||
matInput
|
matInput
|
||||||
placeholder="Search by agent, client, branch or account"
|
[placeholder]="
|
||||||
|
listView === 'agents' ? 'Search by agent, client, branch or account' : 'Search by slab, band or submitter'
|
||||||
|
"
|
||||||
(keyup)="applyFilter(filterInput.value)"
|
(keyup)="applyFilter(filterInput.value)"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Agents ─────────────────────────────────────────────────────────────────────────────── -->
|
||||||
|
@if (listView !== 'agents') {
|
||||||
|
<!-- shown under Commission Slabs -->
|
||||||
|
} @else if (dataSource.data.length > 0) {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table mat-table [dataSource]="dataSource" class="modern-table">
|
<table mat-table [dataSource]="dataSource" class="modern-table">
|
||||||
<ng-container matColumnDef="request">
|
<ng-container matColumnDef="request">
|
||||||
@@ -80,6 +112,84 @@
|
|||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let row">
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<ng-container *ngTemplateOutlet="actionsTpl; context: { row: row }"></ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="aa-empty">{{ loading ? 'Loading agent requests…' : 'No agent requests waiting.' }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Commission slabs ───────────────────────────────────────────────────────────────────── -->
|
||||||
|
@if (listView !== 'slabs') {
|
||||||
|
<!-- shown under Agents -->
|
||||||
|
} @else if (slabSource.data.length > 0) {
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="slabSource" class="modern-table">
|
||||||
|
<ng-container matColumnDef="request">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Request</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<span class="status-badge status-badge--action">{{ requestLabel(row) }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="slabType">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Slab</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<strong>{{ slabTypeLabel(row.slabType) }}</strong>
|
||||||
|
<div class="cell-mono">{{ row.resourceId ? '#' + row.resourceId : 'Number on approval' }}</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="band">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Collection Band</th>
|
||||||
|
<td mat-cell *matCellDef="let row">{{ rupees(row.slabFrom) }} – {{ rupees(row.slabTo) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="commission">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Commission</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<strong>{{ percent(row.commissionPercent) }}</strong>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="maker">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||||
|
<td mat-cell *matCellDef="let row">{{ row.makerName || row.maker }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="madeOn">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||||
|
<td mat-cell *matCellDef="let row">{{ formatDate(row.submittedOn) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="status">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<span class="status-badge status-badge--pending">{{ row.status }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
|
<td mat-cell *matCellDef="let row">
|
||||||
|
<ng-container *ngTemplateOutlet="actionsTpl; context: { row: row }"></ng-container>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="slabColumns"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: slabColumns" class="table-row"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="aa-empty">{{ loading ? 'Loading slab requests…' : 'No commission slab requests waiting.' }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #actionsTpl let-row="row">
|
||||||
<div class="aa-actions">
|
<div class="aa-actions">
|
||||||
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
|
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
|
||||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
|
||||||
@@ -104,22 +214,5 @@
|
|||||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</ng-template>
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else if (loading) {
|
|
||||||
<div class="empty-state">
|
|
||||||
<p class="empty-title">Loading agent requests…</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="empty-state">
|
|
||||||
<fa-icon icon="user-tie" class="empty-icon"></fa-icon>
|
|
||||||
<p class="empty-title">No agent requests waiting</p>
|
|
||||||
<p class="empty-text">There are no agents awaiting approval at this time.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+87
@@ -28,3 +28,90 @@ td.mat-column-actions {
|
|||||||
width: 1%;
|
width: 1%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agents or commission slabs, picked by the two buttons at the top, with the search beside them.
|
||||||
|
.aa-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aa-search {
|
||||||
|
flex: 0 1 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aa-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 18%);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aa-switch__count {
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6f4f0;
|
||||||
|
color: #0f4f3c;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aa-empty {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|||||||
+71
-6
@@ -33,8 +33,9 @@ import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-appro
|
|||||||
import { ddsMadeOn } from '../../product-entities';
|
import { ddsMadeOn } from '../../product-entities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened,
|
* Approvals › Agent & Slab Approval: every DDS agent request waiting on a checker - an agent being
|
||||||
* changed or closed.
|
* opened, changed or closed - and every commission slab being added or changed, since the slabs are
|
||||||
|
* what an agent is paid by.
|
||||||
*
|
*
|
||||||
* An agent has no voucher and no amount, so the money columns of DDS Approvals say nothing about one.
|
* An agent has no voucher and no amount, so the money columns of DDS Approvals say nothing about one.
|
||||||
* This lists them by what identifies an agent - who they are, which branch, which savings account the
|
* This lists them by what identifies an agent - who they are, which branch, which savings account the
|
||||||
@@ -73,8 +74,28 @@ export class AgentApprovalsComponent implements OnInit {
|
|||||||
private readonly currentUserId = this.authenticationService.getCredentials()?.userId;
|
private readonly currentUserId = this.authenticationService.getCredentials()?.userId;
|
||||||
|
|
||||||
dataSource = new MatTableDataSource<any>([]);
|
dataSource = new MatTableDataSource<any>([]);
|
||||||
|
/** Commission slab requests: a slab is what an agent's commission is worked out by. */
|
||||||
|
slabSource = new MatTableDataSource<any>([]);
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
|
/** Which list is on show: agents by default, commission slabs from the button beside it. */
|
||||||
|
listView: 'agents' | 'slabs' = 'agents';
|
||||||
|
|
||||||
|
show(view: 'agents' | 'slabs'): void {
|
||||||
|
this.listView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
slabColumns: string[] = [
|
||||||
|
'request',
|
||||||
|
'slabType',
|
||||||
|
'band',
|
||||||
|
'commission',
|
||||||
|
'maker',
|
||||||
|
'madeOn',
|
||||||
|
'status',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'request',
|
'request',
|
||||||
'agent',
|
'agent',
|
||||||
@@ -104,6 +125,18 @@ export class AgentApprovalsComponent implements OnInit {
|
|||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filter);
|
.includes(filter);
|
||||||
|
this.slabSource.filterPredicate = (row: any, filter: string) =>
|
||||||
|
[
|
||||||
|
row.slabType,
|
||||||
|
row.details,
|
||||||
|
row.type,
|
||||||
|
row.maker,
|
||||||
|
row.makerName
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(filter);
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,19 +146,51 @@ export class AgentApprovalsComponent implements OnInit {
|
|||||||
.getDdsApprovals('PENDING')
|
.getDdsApprovals('PENDING')
|
||||||
.pipe(catchError(() => of([])))
|
.pipe(catchError(() => of([])))
|
||||||
.subscribe((rows: any) => {
|
.subscribe((rows: any) => {
|
||||||
const agents = (Array.isArray(rows) ? rows : []).filter((row: any) => row?.entity === 'AGENT');
|
const all = (Array.isArray(rows) ? rows : []).map((row: any) => ({
|
||||||
this.dataSource.data = agents.map((row: any) => ({ ...row, submittedOn: ddsMadeOn(row.madeOn) }));
|
...row,
|
||||||
|
submittedOn: ddsMadeOn(row.madeOn)
|
||||||
|
}));
|
||||||
|
this.dataSource.data = all.filter((row: any) => row?.entity === 'AGENT');
|
||||||
|
this.slabSource.data = all.filter((row: any) => row?.entity === 'COMMISSIONSLAB');
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFilter(value: string = ''): void {
|
applyFilter(value: string = ''): void {
|
||||||
this.dataSource.filter = value.trim().toLowerCase();
|
this.dataSource.filter = value.trim().toLowerCase();
|
||||||
|
this.slabSource.filter = value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the request is, as the badge reads it. */
|
get hasRequests(): boolean {
|
||||||
|
return this.dataSource.data.length > 0 || this.slabSource.data.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the request is, as the badge reads it: "New Agent", "Edit Slab", "Close Agent". */
|
||||||
requestLabel(row: any): string {
|
requestLabel(row: any): string {
|
||||||
return row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit';
|
const verb = row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit';
|
||||||
|
return `${verb} ${row.entity === 'COMMISSIONSLAB' ? 'Slab' : 'Agent'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ACCOUNT → Account. */
|
||||||
|
slabTypeLabel(type: string | null | undefined): string {
|
||||||
|
return type ? type.charAt(0).toUpperCase() + type.slice(1).toLowerCase() : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly RUPEES = new Intl.NumberFormat('en-IN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'INR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
rupees(value: any): string {
|
||||||
|
return value === null || value === undefined || value === ''
|
||||||
|
? '—'
|
||||||
|
: AgentApprovalsComponent.RUPEES.format(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
percent(value: any): string {
|
||||||
|
return value === null || value === undefined || value === '' ? '—' : `${Number(value)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
isMine(row: any): boolean {
|
isMine(row: any): boolean {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./agent-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./agent-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="user-tie" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-tie" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Agent Approval</span>
|
<span>Agent & Slab Approval</span>
|
||||||
@if (count('agent-approvals')) {
|
@if (count('agent-approvals')) {
|
||||||
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
|
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ export function isDdsRoutedRequest(
|
|||||||
actionName: string | undefined | null
|
actionName: string | undefined | null
|
||||||
): boolean {
|
): boolean {
|
||||||
const entity = (entityName ?? '').toUpperCase();
|
const entity = (entityName ?? '').toUpperCase();
|
||||||
// An agent request of any kind waits under Agent Approval: an agent has no voucher and no amount,
|
// An agent or commission slab request of any kind waits under Agent & Slab Approval: neither has a
|
||||||
// so the money columns of DDS Approvals say nothing about one.
|
// voucher or an amount, so the money columns of DDS Approvals say nothing about one.
|
||||||
if (entity === 'AGENT') {
|
if (entity === 'AGENT' || entity === 'COMMISSIONSLAB') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'agent-approvals',
|
path: 'agent-approvals',
|
||||||
component: AgentApprovalsComponent,
|
component: AgentApprovalsComponent,
|
||||||
data: { title: 'Agent Approval' }
|
data: { title: 'Agent & Slab Approval' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'chequebook-requests',
|
path: 'chequebook-requests',
|
||||||
|
|||||||
Reference in New Issue
Block a user