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 { Dates } from 'app/core/utils/dates';
|
||||
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
|
||||
import { isDdsClosed } from '../dds-status';
|
||||
|
||||
/**
|
||||
* Daily Transaction Entry component.
|
||||
@@ -673,7 +674,7 @@ export class DailyTransactionEntryComponent implements OnInit {
|
||||
this.organizationService.getParties().subscribe({
|
||||
next: (parties: any) => {
|
||||
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('debit');
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
|
||||
<div class="dav-banner__text">
|
||||
<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>
|
||||
<div class="dav-banner__meta">
|
||||
@@ -45,12 +45,37 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
|
||||
<fa-icon icon="pen"></fa-icon>
|
||||
</button>
|
||||
@if (isOpen) {
|
||||
<button
|
||||
class="dav-icon-btn"
|
||||
[matMenuTriggerFor]="accountMenu"
|
||||
matTooltip="More actions"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<fa-icon icon="ellipsis-vertical"></fa-icon>
|
||||
</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>
|
||||
|
||||
@@ -72,7 +97,7 @@
|
||||
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
|
||||
<span>Nominees</span>
|
||||
</a>
|
||||
@if (account.status === 'CLOSED') {
|
||||
@if (!isOpen) {
|
||||
<a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
|
||||
<fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
|
||||
<span>Closure</span>
|
||||
@@ -582,7 +607,7 @@
|
||||
<div class="detail-block">
|
||||
<div class="detail-label">Status</div>
|
||||
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
|
||||
{{ account.status }}
|
||||
{{ statusLabel(account.status) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
|
||||
@@ -82,6 +82,18 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
|
||||
@@ -41,6 +41,12 @@ import { catchError } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
||||
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. */
|
||||
const TRANSACTION_LABELS: Record<string, string> = {
|
||||
@@ -50,7 +56,7 @@ const TRANSACTION_LABELS: Record<string, string> = {
|
||||
MATURITY_PAYOUT: 'Maturity Payout',
|
||||
PREMATURE_PAYOUT: 'Premature Payout',
|
||||
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)',
|
||||
TRANSFER: 'Transfer'
|
||||
};
|
||||
@@ -107,6 +113,9 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
isLoading = true;
|
||||
account: any = null;
|
||||
@@ -318,7 +327,7 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
|
||||
}).subscribe(({ account, transactions }) => {
|
||||
this.account = account;
|
||||
if (account?.status === 'CLOSED') {
|
||||
if (isDdsClosed(account?.status)) {
|
||||
this.organizationService.getPartyClosure(partyId).subscribe({
|
||||
next: (closure: any) => (this.closure = closure),
|
||||
error: () => (this.closure = null)
|
||||
@@ -412,7 +421,7 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
return (
|
||||
this.totalOf('INTEREST_POST') +
|
||||
this.totalOf('OD_INTEREST_POST') +
|
||||
this.totalOf('PREMATURE_AGENT_TOP_UP') -
|
||||
this.totalOf('PREMATURE_AGENT_TOPUP') -
|
||||
recovered
|
||||
);
|
||||
}
|
||||
@@ -521,6 +530,8 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
readonly statusLabel = ddsStatusLabel;
|
||||
|
||||
label(type: string): string {
|
||||
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
|
||||
}
|
||||
@@ -529,15 +540,28 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
switch (this.account?.status) {
|
||||
case 'ACTIVE':
|
||||
return 'status--active';
|
||||
case 'MATURED':
|
||||
return 'status--default';
|
||||
case 'CLOSED':
|
||||
case 'PREMATURE_CLOSED':
|
||||
return 'status--closed';
|
||||
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 {
|
||||
this.router.navigate(['/accounts/dds-accounts']);
|
||||
const agent = this.route.snapshot.queryParamMap.get('agent');
|
||||
if (agent) {
|
||||
this.router.navigate(['/accounts/dds'], { queryParams: { agent } });
|
||||
} else {
|
||||
this.router.navigate(['/accounts/dds-accounts']);
|
||||
}
|
||||
}
|
||||
|
||||
openInPartyMaster(): void {
|
||||
@@ -547,4 +571,127 @@ export class DdsAccountViewComponent implements OnInit {
|
||||
'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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dds-status-filter__btn dds-status-filter__btn--active"
|
||||
[class.is-on]="statusFilter === 'ACTIVE'"
|
||||
(click)="setStatusFilter('ACTIVE')"
|
||||
>
|
||||
Active <span class="dds-status-filter__count">{{ activeCount }}</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>
|
||||
@for (filter of statusFilters; track filter.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="dds-status-filter__btn"
|
||||
[class.dds-status-filter__btn--active]="filter.value === 'ACTIVE'"
|
||||
[class.dds-status-filter__btn--closed]="filter.value === 'CLOSED' || filter.value === 'PREMATURE_CLOSED'"
|
||||
[class.is-on]="statusFilter === filter.value"
|
||||
(click)="setStatusFilter(filter.value)"
|
||||
>
|
||||
{{ filter.label }} <span class="dds-status-filter__count">{{ countOf(filter.value) }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Date Filters: by the day the account was opened -->
|
||||
@@ -179,7 +175,7 @@
|
||||
<ng-container matColumnDef="status">
|
||||
<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">
|
||||
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
|
||||
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ statusLabel(row.status) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
||||
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,
|
||||
@@ -93,7 +96,7 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
||||
private matching: any[] = [];
|
||||
|
||||
/** 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. */
|
||||
rows: any[] = [];
|
||||
|
||||
@@ -113,19 +116,25 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
||||
return this.matching.length;
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
|
||||
/** How many accounts the search finds in each status - one button per status. */
|
||||
countOf(status: string): number {
|
||||
return this.beforeStatus.filter((account) => account.status === status).length;
|
||||
}
|
||||
|
||||
get closedCount(): number {
|
||||
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
|
||||
}
|
||||
readonly statusFilters: { value: DdsStatusFilter; label: string }[] = [
|
||||
{ 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 {
|
||||
return this.beforeStatus.length;
|
||||
}
|
||||
|
||||
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
|
||||
setStatusFilter(status: DdsStatusFilter): void {
|
||||
this.statusFilter = status;
|
||||
this.pageIndex = 0;
|
||||
this.applyFilters();
|
||||
@@ -257,6 +266,7 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
|
||||
case 'ACTIVE':
|
||||
return 'status--active';
|
||||
case 'CLOSED':
|
||||
case 'PREMATURE_CLOSED':
|
||||
return 'status--closed';
|
||||
default:
|
||||
return 'status--default';
|
||||
|
||||
+189
-2
@@ -19,7 +19,7 @@
|
||||
<span>Loading entry…</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
|
||||
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview || isSlabReview">
|
||||
@if (isProductReview) {
|
||||
<!--
|
||||
A DDS product request reads as the product it creates or changes: who and what at the top,
|
||||
@@ -205,6 +205,190 @@
|
||||
}}</span>
|
||||
}
|
||||
</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) {
|
||||
<!--
|
||||
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) {
|
||||
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
|
||||
<div
|
||||
class="dri-actions"
|
||||
[class.ddr-actions]="isProductReview || isAgentReview || isPartyReview || isSlabReview"
|
||||
>
|
||||
@if (!confirmReject) {
|
||||
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
|
||||
<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 ──────────────────────────────────────────────────────────────
|
||||
.ddr-banner {
|
||||
display: flex;
|
||||
@@ -262,6 +345,13 @@ $shadow-sm:
|
||||
border-color: $border;
|
||||
color: $text-mid;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
margin-bottom: 12px;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sections ─────────────────────────────────────────────────────────────────
|
||||
|
||||
+47
-1
@@ -104,6 +104,51 @@ export class DdsApprovalReviewComponent implements OnInit {
|
||||
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. */
|
||||
get nominees(): any[] {
|
||||
return this.detail?.nominees || [];
|
||||
@@ -161,7 +206,8 @@ export class DdsApprovalReviewComponent implements OnInit {
|
||||
Agent: 'badge',
|
||||
'Address & Contact': 'location_on',
|
||||
'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. */
|
||||
|
||||
@@ -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-meta">
|
||||
{{ accountSchemeName }} ·
|
||||
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{
|
||||
partyStatus === 'ACTIVE' ? 'Active' : partyStatus
|
||||
}}</span>
|
||||
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{ statusLabel(partyStatus) }}</span>
|
||||
· {{ accountBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1141,35 +1139,6 @@
|
||||
<div class="pm-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</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) {
|
||||
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
<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 { Dates } from 'app/core/utils/dates';
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { ddsStatusLabel, isDdsClosed } from '../../dds-status';
|
||||
|
||||
/** Custom Components */
|
||||
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
|
||||
|
||||
/** Custom Directives */
|
||||
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
|
||||
* 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
|
||||
* backend rejects the command otherwise. A closed Party can no longer be edited or receive new
|
||||
* Party Tran collections.
|
||||
* Closing an account - at nil balance, or early - is done from the DDS account page's actions menu,
|
||||
* not from this form. A closed Party can no longer be edited or receive new Party Tran collections.
|
||||
*
|
||||
* 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) -
|
||||
@@ -296,8 +295,10 @@ export class CreatePartyComponent implements OnInit {
|
||||
agentOptions: any[] = [];
|
||||
private allAgents: any[] = [];
|
||||
|
||||
readonly statusLabel = ddsStatusLabel;
|
||||
|
||||
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. */
|
||||
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. */
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,21 +9,29 @@
|
||||
<div class="pl-root">
|
||||
<div class="pl-header">
|
||||
<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">
|
||||
<h2>Party Master</h2>
|
||||
<p>The DDS accounts on file — each one a customer collected from daily.</p>
|
||||
<h2>{{ pageTitle }}</h2>
|
||||
<p>{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-header-right">
|
||||
<div class="pl-count-chip" matTooltip="Total parties">
|
||||
<fa-icon icon="users"></fa-icon>
|
||||
<strong>{{ totalCount }}</strong>
|
||||
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
|
||||
</div>
|
||||
@if (selectedAgent) {
|
||||
<div class="pl-count-chip" matTooltip="Accounts this agent collects for">
|
||||
<fa-icon icon="users"></fa-icon>
|
||||
<strong>{{ selectedAgent.accounts }}</strong>
|
||||
<span>{{ selectedAgent.accounts === 1 ? 'account' : 'accounts' }}</span>
|
||||
</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']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Party</span>
|
||||
<span>Open DDS Account</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,57 +61,432 @@
|
||||
</a>
|
||||
}
|
||||
|
||||
<div class="pl-kpis">
|
||||
<div class="pl-kpi pl-kpi--blue">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ activeCount | number }}</div>
|
||||
<div class="pl-kpi-l">Open Accounts</div>
|
||||
<!--
|
||||
The page level shows only the counts - agents and accounts - so it reads at a glance. The figures
|
||||
are worked out agent by agent, and an agent's own page shows them in full.
|
||||
-->
|
||||
@if (loaded && totalCount > 0) {
|
||||
@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
|
||||
class="pl-kpi pl-kpi--purple"
|
||||
matTooltip="What the agents should collect between them on a full collection day"
|
||||
>
|
||||
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Daily Collection Due</div>
|
||||
} @else if (agentLoading) {
|
||||
<!-- The agent's accounts are on their way; their figures are worked out from them. -->
|
||||
<div class="pm-loading">
|
||||
<span class="pm-loading__spin"></span>
|
||||
Loading {{ selectedAgent.agentName }}'s accounts…
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
|
||||
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
|
||||
<div>
|
||||
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
|
||||
<div class="pl-kpi-l">Total Deposits Held</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')"
|
||||
>
|
||||
<span class="pm-kpi__ic"><fa-icon icon="users"></fa-icon></span>
|
||||
<span class="pm-kpi__body">
|
||||
<b>{{ figures.accounts }}</b>
|
||||
<small>Total accounts</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--green pm-kpi--click"
|
||||
[class.pm-kpi--on]="cardKey === 'active2'"
|
||||
(click)="showView('active', 'active2')"
|
||||
>
|
||||
<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>
|
||||
</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-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">
|
||||
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
(input)="applyFilter($any($event.target).value)"
|
||||
placeholder="Search by code, name or agent…"
|
||||
/>
|
||||
<!-- Tracked so switching between agents and accounts starts the search over. -->
|
||||
@if (selectedAgent) {
|
||||
<input
|
||||
type="text"
|
||||
(input)="applyFilter($any($event.target).value)"
|
||||
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>
|
||||
|
||||
@if (totalCount === 0) {
|
||||
<div class="pl-empty">
|
||||
<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>
|
||||
<button class="pl-create-btn" type="button" [routerLink]="['create']">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
<span>Create Party</span>
|
||||
<span>Open DDS Account</span>
|
||||
</button>
|
||||
</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 {
|
||||
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 {
|
||||
<div class="pl-table-wrap">
|
||||
@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">
|
||||
<ng-container matColumnDef="partyCode">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
|
||||
@@ -175,11 +558,22 @@
|
||||
</td>
|
||||
</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">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let p">
|
||||
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
|
||||
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
|
||||
<span
|
||||
class="pm-pill"
|
||||
[class.pm-pill--off]="isClosed(p)"
|
||||
[class.pm-pill--matured]="p.status === 'MATURED'"
|
||||
>
|
||||
{{ statusLabel(p.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -204,16 +598,29 @@
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<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--edit"
|
||||
[routerLink]="[p.id, 'edit']"
|
||||
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
|
||||
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]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
|
||||
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
<span>View</span>
|
||||
</button>
|
||||
@if (!isClosed(p)) {
|
||||
<button
|
||||
type="button"
|
||||
class="pl-action-btn pl-action-btn--edit"
|
||||
[routerLink]="[p.id, 'edit']"
|
||||
matTooltip="Edit this account"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -222,18 +629,23 @@
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="pl-row"
|
||||
[class.pm-row--closed]="row.status === 'CLOSED'"
|
||||
[class.pm-row--closed]="isClosed(row)"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
@if (filteredCount === 0 && totalCount > 0) {
|
||||
@if (dataSource.filteredData.length === 0 && totalCount > 0) {
|
||||
<div class="pl-no-match">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<span>No parties match your filter.</span>
|
||||
<span>No accounts of {{ selectedAgent.agentName }} match your search.</span>
|
||||
</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>
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
color: rgb(0 0 0 / 52%);
|
||||
}
|
||||
|
||||
.pm-pill--matured {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* The customer's real deposit account number, in the list. */
|
||||
.pm-list-account {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
|
||||
@@ -131,3 +136,626 @@
|
||||
.pl-muted {
|
||||
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 */
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTable,
|
||||
@@ -28,19 +28,77 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { ddsStatusLabel, isDdsClosed } from '../dds-status';
|
||||
|
||||
/** Custom Components */
|
||||
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
|
||||
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
|
||||
* shape as Agent Master and the product lists, and shares their stylesheet.
|
||||
* The first level is the agents - the book's counts, then each agent with what they collect for - read
|
||||
* as one summary line per agent, so it opens as fast with a hundred thousand accounts as with ten.
|
||||
* 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({
|
||||
selector: 'mifosx-party-master',
|
||||
@@ -66,17 +124,110 @@ import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-d
|
||||
]
|
||||
})
|
||||
export class PartyMasterComponent implements OnInit {
|
||||
readonly isClosed = (p: any): boolean => isDdsClosed(p?.status);
|
||||
readonly statusLabel = ddsStatusLabel;
|
||||
|
||||
private organizationService = inject(OrganizationService);
|
||||
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[] = [
|
||||
'partyCode',
|
||||
'partyName',
|
||||
'subType',
|
||||
'agentName',
|
||||
'savingsAccountNo',
|
||||
'dailyDepositAmount',
|
||||
'currentBalance',
|
||||
'schedule',
|
||||
'nominees',
|
||||
'status',
|
||||
'actions'
|
||||
@@ -114,15 +265,15 @@ export class PartyMasterComponent implements OnInit {
|
||||
dueForClosurePayable = 0;
|
||||
|
||||
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
|
||||
parties: any[] = [];
|
||||
filteredCount = 0;
|
||||
loaded = false;
|
||||
|
||||
paginator: MatPaginator;
|
||||
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.
|
||||
@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;
|
||||
if (paginator) {
|
||||
this.dataSource.paginator = paginator;
|
||||
@@ -137,33 +288,75 @@ export class PartyMasterComponent implements OnInit {
|
||||
}
|
||||
|
||||
get totalCount(): number {
|
||||
return this.parties.length;
|
||||
return this.book.accounts;
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.parties.filter((party) => party.status !== 'CLOSED').length;
|
||||
/** The open agent's full figures; the agent page shows them once the accounts are in. */
|
||||
get figures(): BookFigures {
|
||||
return this.agentFigures ?? PartyMasterComponent.NO_FIGURES;
|
||||
}
|
||||
|
||||
/** What the whole book is worth today - every open account's running balance added up. */
|
||||
get totalCurrentBalance(): number {
|
||||
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
|
||||
private static readonly NO_FIGURES: BookFigures = PartyMasterComponent.figuresOf([]);
|
||||
|
||||
/** 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 totalDailyDue(): number {
|
||||
return this.parties
|
||||
.filter((party) => party.status !== 'CLOSED')
|
||||
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
|
||||
get pageSubtitle(): string {
|
||||
if (this.selectedAgent) {
|
||||
const a = this.selectedAgent;
|
||||
return `Agent ${a.agentCode}${a.officeName ? ' · ' + a.officeName : ''} · ${a.open} open ${
|
||||
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 {
|
||||
this.organizationService.getParties().subscribe((parties: any[]) => {
|
||||
this.parties = parties || [];
|
||||
this.dataSource = new MatTableDataSource(this.parties);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.filteredCount = this.parties.length;
|
||||
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
|
||||
this.organizationService.getDdsAgentSummary().subscribe((rows: any[]) => {
|
||||
this.agentGroups = (rows || []).map((row: any) => ({
|
||||
agentId: row.agentId ?? null,
|
||||
agentCode: row.agentCode || '—',
|
||||
agentName: row.agentName || 'No agent',
|
||||
officeName: row.officeName || '',
|
||||
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();
|
||||
@@ -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 {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
if (this.selectedAgent) {
|
||||
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 =
|
||||
signed('INTEREST_POST') +
|
||||
signed('OD_INTEREST_POST') +
|
||||
signed('PREMATURE_AGENT_TOP_UP') +
|
||||
signed('PREMATURE_AGENT_TOPUP') +
|
||||
signed('PREMATURE_INT_RECOVERY');
|
||||
const balance = Number(this.data.currentBalance || 0);
|
||||
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
|
||||
|
||||
+333
-145
@@ -6,170 +6,358 @@
|
||||
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) {
|
||||
<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 term still to run is the whole reason there is a charge at all.
|
||||
-->
|
||||
<div class="pcd-summary">
|
||||
<div class="pcd-row">
|
||||
<span>Customer</span>
|
||||
<strong>{{ data.partyName }}</strong>
|
||||
<div class="pcd-tiles">
|
||||
<div class="pcd-tile">
|
||||
<small>Balance</small>
|
||||
<b>₹{{ data.currentBalance | number: '1.2-2' }}</b>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Balance</span>
|
||||
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
|
||||
<div class="pcd-tile">
|
||||
<small>Scheme rate</small>
|
||||
<b>{{ data.interestRate | number: '1.0-2' }}%</b>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Matures on</span>
|
||||
<strong>{{ data.maturityDate | dateFormat }}</strong>
|
||||
<div class="pcd-tile">
|
||||
<small>Opened</small>
|
||||
<b>{{ data.openingDate | dateFormat }}</b>
|
||||
</div>
|
||||
<div class="pcd-row">
|
||||
<span>Scheme rate</span>
|
||||
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
|
||||
<div class="pcd-tile">
|
||||
<small>Matures on</small>
|
||||
<b>{{ data.maturityDate | dateFormat }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
|
||||
|
||||
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
|
||||
<mat-radio-button value="CUSTOMER"></mat-radio-button>
|
||||
<div class="pcd-choice-body">
|
||||
<div class="pcd-choice-title">Customer</div>
|
||||
@if (scheme) {
|
||||
<div class="pcd-choice-detail">
|
||||
Interest recalculated at
|
||||
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
|
||||
before the balance is paid out.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
|
||||
}
|
||||
@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>
|
||||
</label>
|
||||
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
|
||||
<mat-radio-button value="AGENT"></mat-radio-button>
|
||||
<div class="pcd-choice-body">
|
||||
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
|
||||
<div class="pcd-choice-detail">
|
||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
|
||||
paid out in full.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</mat-radio-group>
|
||||
|
||||
<!--
|
||||
Stated rather than offered as a third option: the agent is charged whichever answer is picked, and
|
||||
a clerk who did not know that would be surprised by it afterwards.
|
||||
-->
|
||||
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
|
||||
<p class="pcd-note">
|
||||
Either way, {{ data.agentCode }} is charged
|
||||
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
|
||||
of the commission owed to them — the round was not kept going.
|
||||
</p>
|
||||
}
|
||||
|
||||
<h4 class="pcd-question">Where should the balance go?</h4>
|
||||
|
||||
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
|
||||
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
|
||||
Into another account of theirs at this bank
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Credit to account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountNo">
|
||||
@for (account of ownAccounts; track account.accountNo) {
|
||||
<mat-option [value]="account.accountNo"> {{ account.accountNo }} — {{ account.productName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (destination === 'other') {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Park in GL account</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="sundrySearch"
|
||||
(ngModelChange)="onSundryTyped($event)"
|
||||
[matAutocomplete]="sundryAuto"
|
||||
placeholder="Type GL code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#sundryAuto="matAutocomplete"
|
||||
panelWidth="min(28rem, 90vw)"
|
||||
[displayWith]="sundryDisplay"
|
||||
(optionSelected)="pickSundry($event.option.value)"
|
||||
>
|
||||
@for (account of sundryOptions; track account.id) {
|
||||
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
@if (sundryOptions.length === 0) {
|
||||
<mat-option disabled>No liability GL matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (!sundryGlAccountId) {
|
||||
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (ownAccounts.length === 0 && !loading) {
|
||||
<p class="pcd-note">
|
||||
This customer has no other account here, so the balance is parked in 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:
|
||||
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
|
||||
-->
|
||||
@if (withinLockin) {
|
||||
<div class="pcd-lockin">
|
||||
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
|
||||
<p class="pcd-note">
|
||||
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
|
||||
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
|
||||
</p>
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Reason for closing within lock-in</mat-label>
|
||||
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
|
||||
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="pcd-warn">
|
||||
@if (withinLockin) {
|
||||
Once approved, this closes the account. It cannot be reopened.
|
||||
} @else {
|
||||
This closes the account. It cannot be reopened.
|
||||
}
|
||||
</p>
|
||||
@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">
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
|
||||
<mat-radio-button value="CUSTOMER"></mat-radio-button>
|
||||
<span class="pcd-choice__ic"><fa-icon icon="user"></fa-icon></span>
|
||||
<div class="pcd-choice__body">
|
||||
<div class="pcd-choice__title">Customer</div>
|
||||
@if (scheme) {
|
||||
<div class="pcd-choice__detail">
|
||||
Interest recalculated at
|
||||
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
|
||||
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
|
||||
back before the balance is paid out.
|
||||
</div>
|
||||
} @else {
|
||||
<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>
|
||||
</label>
|
||||
|
||||
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
|
||||
<mat-radio-button value="AGENT"></mat-radio-button>
|
||||
<span class="pcd-choice__ic"><fa-icon icon="user-tie"></fa-icon></span>
|
||||
<div class="pcd-choice__body">
|
||||
<div class="pcd-choice__title">Agent — {{ data.agentCode }}</div>
|
||||
<div class="pcd-choice__detail">
|
||||
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance
|
||||
is paid out in full.
|
||||
</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>
|
||||
</label>
|
||||
</mat-radio-group>
|
||||
|
||||
<!--
|
||||
Stated rather than offered as a third option: the agent is charged whichever answer is picked, and
|
||||
a clerk who did not know that would be surprised by it afterwards.
|
||||
-->
|
||||
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
|
||||
<p class="pcd-info">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span
|
||||
>Either way, {{ data.agentCode }} is charged
|
||||
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
|
||||
of the commission owed to them — the round was not kept going.</span
|
||||
>
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Step 2 -->
|
||||
<h4 class="pcd-step"><span class="pcd-step__no">2</span> Where should the balance go?</h4>
|
||||
|
||||
<mat-radio-group class="pcd-choices pcd-choices--two" [(ngModel)]="destination">
|
||||
<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
|
||||
} @else {
|
||||
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>
|
||||
|
||||
@if (destination === 'own' && ownAccounts.length > 0) {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Credit to account</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAccountNo">
|
||||
@for (account of ownAccounts; track account.accountNo) {
|
||||
<mat-option [value]="account.accountNo"> {{ account.accountNo }} — {{ account.productName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (destination === 'other') {
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Park in GL account</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="sundrySearch"
|
||||
(ngModelChange)="onSundryTyped($event)"
|
||||
[matAutocomplete]="sundryAuto"
|
||||
placeholder="Type GL code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#sundryAuto="matAutocomplete"
|
||||
panelWidth="min(28rem, 90vw)"
|
||||
[displayWith]="sundryDisplay"
|
||||
(optionSelected)="pickSundry($event.option.value)"
|
||||
>
|
||||
@for (account of sundryOptions; track account.id) {
|
||||
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
|
||||
}
|
||||
@if (sundryOptions.length === 0) {
|
||||
<mat-option disabled>No liability GL matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (!sundryGlAccountId) {
|
||||
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!--
|
||||
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
|
||||
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
|
||||
-->
|
||||
@if (withinLockin) {
|
||||
<h4 class="pcd-step"><span class="pcd-step__no">3</span> Reason for closing within lock-in</h4>
|
||||
<div class="pcd-lockin">
|
||||
<p class="pcd-lockin__title">
|
||||
<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
|
||||
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
|
||||
</p>
|
||||
<mat-form-field appearance="outline" class="pcd-field">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
|
||||
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pcd-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
@if (withinLockin) {
|
||||
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 {
|
||||
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>
|
||||
</aside>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
|
||||
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
|
||||
<mat-dialog-actions class="pcd-actions">
|
||||
<span class="pcd-actions__hint">
|
||||
@if (!borneBy) {
|
||||
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>
|
||||
</mat-dialog-actions>
|
||||
|
||||
+486
-67
@@ -6,40 +6,183 @@
|
||||
* 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. */
|
||||
.pcd-summary {
|
||||
margin: 4px 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgb(0 0 0 / 3%);
|
||||
}
|
||||
|
||||
.pcd-row {
|
||||
/* What is being closed, and the status it moves to */
|
||||
.pcd-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 24px;
|
||||
background: linear-gradient(135deg, #1e3a8a, #4338ca);
|
||||
color: #fff;
|
||||
|
||||
span {
|
||||
color: rgb(0 0 0 / 60%);
|
||||
&__ic {
|
||||
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 {
|
||||
font-variant-numeric: tabular-nums;
|
||||
.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-question {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
font-size: 0.9rem;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
|
||||
&--two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -49,42 +192,69 @@
|
||||
.pcd-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid rgb(0 0 0 / 12%);
|
||||
border-radius: 6px;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 2%);
|
||||
border-color: #c7d2fe;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-choice--on {
|
||||
border-color: #b45309;
|
||||
background: rgb(180 83 9 / 5%);
|
||||
}
|
||||
&--on,
|
||||
&--on:hover {
|
||||
border-color: #4338ca;
|
||||
background: #eef2ff;
|
||||
box-shadow: 0 0 0 1px #4338ca;
|
||||
}
|
||||
|
||||
.pcd-choice-body {
|
||||
padding-top: 2px;
|
||||
}
|
||||
&--off {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.pcd-choice-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
&__ic {
|
||||
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;
|
||||
}
|
||||
|
||||
.pcd-choice-detail {
|
||||
margin-top: 3px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(0 0 0 / 65%);
|
||||
}
|
||||
&--on &__ic {
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pcd-destinations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
&__body {
|
||||
min-width: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__detail {
|
||||
margin-top: 3px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-field {
|
||||
@@ -92,41 +262,290 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pcd-note {
|
||||
margin: 12px 0 0;
|
||||
.pcd-info {
|
||||
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;
|
||||
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. */
|
||||
.pcd-warn {
|
||||
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;
|
||||
fa-icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
|
||||
.pcd-lockin {
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgb(180 83 9 / 30%);
|
||||
border-radius: 10px;
|
||||
background: rgb(245 158 11 / 8%);
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
.pcd-lockin-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
/* 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 {
|
||||
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 { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
@@ -56,6 +57,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent,
|
||||
DateFormatPipe
|
||||
],
|
||||
templateUrl: './premature-close-dialog.component.html',
|
||||
@@ -174,6 +176,113 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
},
|
||||
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. */
|
||||
@@ -183,6 +292,48 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
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. */
|
||||
lockinReason = '';
|
||||
|
||||
@@ -193,8 +344,8 @@ export class PrematureCloseDialogComponent implements OnInit {
|
||||
*/
|
||||
get closableFrom(): Date | null {
|
||||
const months = Number(this.scheme?.prematureLockinMonths || 0);
|
||||
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
|
||||
if (months <= 0 || !opened || isNaN(opened.getTime())) {
|
||||
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
|
||||
if (months <= 0 || !opened) {
|
||||
return null;
|
||||
}
|
||||
const closable = new Date(opened);
|
||||
|
||||
@@ -170,11 +170,11 @@
|
||||
@if (isPostingJob) {
|
||||
<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
|
||||
press <strong>Post</strong>.
|
||||
press <strong>Post</strong>. <strong>Post goes to a checker for approval</strong> before anything moves.
|
||||
} @else {
|
||||
<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.
|
||||
<strong>Generate</strong> previews it; nothing is recorded until you press <strong>Post</strong>.
|
||||
each account right away - there is nothing to post or approve. It does not become spendable until the
|
||||
matching Posting job runs.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -191,9 +191,9 @@
|
||||
(click)="generate()"
|
||||
>
|
||||
@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 {
|
||||
<i class="fas fa-calculator"></i> Generate
|
||||
<i class="fas fa-calculator"></i> {{ isCalculationJob ? 'Calculate' : 'Generate' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -374,7 +374,10 @@
|
||||
<td>
|
||||
@switch (run.status) {
|
||||
@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') {
|
||||
<span class="bp-status bp-status--running">
|
||||
|
||||
@@ -382,6 +382,11 @@ export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return this.jobType.endsWith('_POSTING');
|
||||
}
|
||||
@@ -475,13 +480,29 @@ export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
|
||||
this.organizationService.generateDdsBatchRun(payload).subscribe({
|
||||
error: () => (this.generating = false),
|
||||
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({
|
||||
error: () => (this.generating = false),
|
||||
next: (batchRun: any) => {
|
||||
this.generating = false;
|
||||
this.loadRecentRuns();
|
||||
// The run is checked on a page of its own - agent by agent, with Post and Discard beside the
|
||||
// figures - so this page does not grow a second screen's worth below the form.
|
||||
if (result?.changes?.calculated) {
|
||||
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([
|
||||
'/organization/dds-batch-processing/run',
|
||||
batchRun.id
|
||||
|
||||
@@ -118,7 +118,7 @@ export class DdsBatchRunPreviewComponent implements OnInit {
|
||||
get statusText(): string {
|
||||
switch (this.generatedRun?.status) {
|
||||
case 'POSTED':
|
||||
return 'Posted';
|
||||
return this.jobType.endsWith('_CALCULATION') ? 'Calculated' : 'Posted';
|
||||
case 'POSTING':
|
||||
return 'Running';
|
||||
case 'FAILED':
|
||||
@@ -331,7 +331,7 @@ export class DdsBatchRunPreviewComponent implements OnInit {
|
||||
(this.isPostingJob
|
||||
? 'This credits the customers or pays the agents, and appears on the ledger. '
|
||||
: '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()
|
||||
|
||||
@@ -272,6 +272,11 @@ export class OrganizationService {
|
||||
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. */
|
||||
getNextPartyCode(): Observable<any> {
|
||||
return this.http.get('/dds/parties/nextcode');
|
||||
|
||||
+136
-43
@@ -7,19 +7,51 @@
|
||||
-->
|
||||
|
||||
<div class="psp-tab-content">
|
||||
@if (dataSource.data.length > 0) {
|
||||
<div class="psp-action-bar">
|
||||
<mat-form-field appearance="outline" class="psp-search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
matInput
|
||||
placeholder="Search by agent, client, branch or account"
|
||||
(keyup)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<!-- Agents or commission slabs, one at a time; agents first, as most requests are theirs. -->
|
||||
<div class="aa-bar">
|
||||
<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>
|
||||
<input
|
||||
#filterInput
|
||||
matInput
|
||||
[placeholder]="
|
||||
listView === 'agents' ? 'Search by agent, client, branch or account' : 'Search by slab, band or submitter'
|
||||
"
|
||||
(keyup)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- ── Agents ─────────────────────────────────────────────────────────────────────────────── -->
|
||||
@if (listView !== 'agents') {
|
||||
<!-- shown under Commission Slabs -->
|
||||
} @else if (dataSource.data.length > 0) {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="dataSource" class="modern-table">
|
||||
<ng-container matColumnDef="request">
|
||||
@@ -80,30 +112,7 @@
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="aa-actions">
|
||||
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
|
||||
</button>
|
||||
<!-- Not offered to whoever made the request - another user with checker rights has to act. -->
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="psp-btn psp-btn-approve"
|
||||
[disabled]="!canAct(row)"
|
||||
[title]="isMine(row) ? 'You made this request, so another user has to approve it' : ''"
|
||||
(click)="approve(row)"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="psp-btn psp-btn-reject"
|
||||
[disabled]="!canAct(row)"
|
||||
[title]="isMine(row) ? 'You made this request, so another user has to reject it' : ''"
|
||||
(click)="reject(row)"
|
||||
>
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
</div>
|
||||
<ng-container *ngTemplateOutlet="actionsTpl; context: { row: row }"></ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -111,15 +120,99 @@
|
||||
<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>
|
||||
} @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 {
|
||||
<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>
|
||||
<p class="aa-empty">{{ loading ? 'Loading slab requests…' : 'No commission slab requests waiting.' }}</p>
|
||||
}
|
||||
|
||||
<ng-template #actionsTpl let-row="row">
|
||||
<div class="aa-actions">
|
||||
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="view(row)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
|
||||
</button>
|
||||
<!-- Not offered to whoever made the request - another user with checker rights has to act. -->
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="psp-btn psp-btn-approve"
|
||||
[disabled]="!canAct(row)"
|
||||
[title]="isMine(row) ? 'You made this request, so another user has to approve it' : ''"
|
||||
(click)="approve(row)"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="psp-btn psp-btn-reject"
|
||||
[disabled]="!canAct(row)"
|
||||
[title]="isMine(row) ? 'You made this request, so another user has to reject it' : ''"
|
||||
(click)="reject(row)"
|
||||
>
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
+87
@@ -28,3 +28,90 @@ td.mat-column-actions {
|
||||
width: 1%;
|
||||
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';
|
||||
|
||||
/**
|
||||
* Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened,
|
||||
* changed or closed.
|
||||
* Approvals › Agent & Slab Approval: every DDS agent request waiting on a checker - an agent being
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
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;
|
||||
|
||||
/** 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[] = [
|
||||
'request',
|
||||
'agent',
|
||||
@@ -104,6 +125,18 @@ export class AgentApprovalsComponent implements OnInit {
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -113,19 +146,51 @@ export class AgentApprovalsComponent implements OnInit {
|
||||
.getDdsApprovals('PENDING')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any) => {
|
||||
const agents = (Array.isArray(rows) ? rows : []).filter((row: any) => row?.entity === 'AGENT');
|
||||
this.dataSource.data = agents.map((row: any) => ({ ...row, submittedOn: ddsMadeOn(row.madeOn) }));
|
||||
const all = (Array.isArray(rows) ? rows : []).map((row: any) => ({
|
||||
...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;
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(value: string = ''): void {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
</a>
|
||||
<a class="ac-tab" routerLink="./agent-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="user-tie" class="ac-tab__ic"></fa-icon>
|
||||
<span>Agent Approval</span>
|
||||
<span>Agent & Slab Approval</span>
|
||||
@if (count('agent-approvals')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ export function isDdsRoutedRequest(
|
||||
actionName: string | undefined | null
|
||||
): boolean {
|
||||
const entity = (entityName ?? '').toUpperCase();
|
||||
// An agent request of any kind waits under Agent Approval: an agent has no voucher and no amount,
|
||||
// so the money columns of DDS Approvals say nothing about one.
|
||||
if (entity === 'AGENT') {
|
||||
// An agent or commission slab request of any kind waits under Agent & Slab Approval: neither has a
|
||||
// voucher or an amount, so the money columns of DDS Approvals say nothing about one.
|
||||
if (entity === 'AGENT' || entity === 'COMMISSIONSLAB') {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
|
||||
@@ -239,7 +239,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'agent-approvals',
|
||||
component: AgentApprovalsComponent,
|
||||
data: { title: 'Agent Approval' }
|
||||
data: { title: 'Agent & Slab Approval' }
|
||||
},
|
||||
{
|
||||
path: 'chequebook-requests',
|
||||
|
||||
Reference in New Issue
Block a user