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:
2026-10-29 18:33:15 +05:30
co-authored by Claude Opus 5.5
parent c766a6f092
commit 1f87ad475c
29 changed files with 3409 additions and 556 deletions
@@ -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 &amp; 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';
@@ -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 ─────────────────────────────────────────────────────────────────
@@ -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. */
+31
View File
@@ -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
@@ -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>
@@ -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%;
}
}
@@ -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');
@@ -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>
@@ -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;
}
@@ -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 &amp; Slab Approval</span>
@if (count('agent-approvals')) {
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
}
+3 -3
View File
@@ -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 [
+1 -1
View File
@@ -239,7 +239,7 @@ const routes: Routes = [
{
path: 'agent-approvals',
component: AgentApprovalsComponent,
data: { title: 'Agent Approval' }
data: { title: 'Agent & Slab Approval' }
},
{
path: 'chequebook-requests',