From 1f87ad475cc3e04fe07de51b3512254c4235a6b9 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 29 Oct 2026 18:33:15 +0530 Subject: [PATCH] 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 --- .../daily-transaction-entry.component.ts | 3 +- .../dds-account-view.component.html | 39 +- .../dds-account-view.component.scss | 12 + .../dds-account-view.component.ts | 155 ++++- .../dds-accounts/dds-accounts.component.html | 30 +- .../dds-accounts/dds-accounts.component.ts | 24 +- .../dds-approval-review.component.html | 191 +++++- .../dds-approval-review.component.scss | 90 +++ .../dds-approval-review.component.ts | 48 +- src/app/deposits/dds-status.ts | 31 + .../create-party/create-party.component.html | 33 +- .../create-party/create-party.component.ts | 130 +--- .../party-master/party-master.component.html | 518 +++++++++++++-- .../party-master/party-master.component.scss | 628 ++++++++++++++++++ .../party-master/party-master.component.ts | 443 +++++++++++- .../payout-dialog/payout-dialog.component.ts | 2 +- .../premature-close-dialog.component.html | 478 +++++++++---- .../premature-close-dialog.component.scss | 553 +++++++++++++-- .../premature-close-dialog.component.ts | 155 ++++- .../dds-batch-processing.component.html | 15 +- .../dds-batch-processing.component.ts | 25 +- .../dds-batch-run-preview.component.ts | 4 +- src/app/organization/organization.service.ts | 5 + .../agent-approvals.component.html | 179 +++-- .../agent-approvals.component.scss | 87 +++ .../agent-approvals.component.ts | 77 ++- .../checker-inbox-and-tasks.component.html | 2 +- src/app/tasks/product-entities.ts | 6 +- src/app/tasks/tasks-routing.module.ts | 2 +- 29 files changed, 3409 insertions(+), 556 deletions(-) create mode 100644 src/app/deposits/dds-status.ts diff --git a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts index 171db5430..8519922f8 100644 --- a/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts +++ b/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts @@ -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'); }, diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html index 5007fde33..cb4e93713 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html @@ -24,7 +24,7 @@
● {{ account.status }}● {{ statusLabel(account.status) }}

{{ account.subType }}

@@ -45,12 +45,37 @@
- - + @if (isOpen) { + + + + @if (canCloseEarly) { + + } + @if (isMaturityDue) { + + } + + }
@@ -72,7 +97,7 @@ Nominees - @if (account.status === 'CLOSED') { + @if (!isOpen) { Closure @@ -582,7 +607,7 @@
Status
- {{ account.status }} + {{ statusLabel(account.status) }}
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss index 4b5f16eae..11e4765f0 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss @@ -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; diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts index 5ecaab665..8f9e097ba 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -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 = { @@ -50,7 +56,7 @@ const TRANSACTION_LABELS: Record = { 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(); + }); + }); + } } diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.html b/src/app/deposits/dds-accounts/dds-accounts.component.html index e1edbc9c7..3c6b2ffd0 100644 --- a/src/app/deposits/dds-accounts/dds-accounts.component.html +++ b/src/app/deposits/dds-accounts/dds-accounts.component.html @@ -64,22 +64,18 @@ > All {{ allCount }} - - + @for (filter of statusFilters; track filter.value) { + + }
@@ -179,7 +175,7 @@ Status - {{ row.status }} + {{ statusLabel(row.status) }} diff --git a/src/app/deposits/dds-accounts/dds-accounts.component.ts b/src/app/deposits/dds-accounts/dds-accounts.component.ts index 811e5e9a3..1a576711f 100644 --- a/src/app/deposits/dds-accounts/dds-accounts.component.ts +++ b/src/app/deposits/dds-accounts/dds-accounts.component.ts @@ -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'; diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html index 5c3f6fb41..a58b9b862 100644 --- a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -19,7 +19,7 @@ Loading entry… } @else { -
+
@if (isProductReview) { +
+
+ {{ isEdit ? 'edit_note' : 'stacked_bar_chart' }} +
+
+
+ {{ isEdit ? 'Edit request' : 'New slab' }} + {{ entry.status }} +
+

{{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }}

+
+ {{ valueOf('slabType') || 'Commission' }} slab + @if (entry.resourceId) { + + #{{ entry.resourceId }} + } +
+
+
+ + person + + {{ entry.makerName || entry.maker }} + {{ entry.maker }} + + + + schedule + + {{ formatDateTime(entry.madeOn) }} + Submitted + + + @if (entry.status !== 'PENDING' && entry.checker) { + + verified_user + + {{ entry.checkerName || entry.checker }} + {{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }} + + + } +
+
+ +
+
+ percentCommission + {{ valueOf('commissionPercent') || '—' }} + of the month's collection +
+
+ straightenCollection band + {{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }} + agent's collection volume +
+
+ calculateExample + {{ slabExample || '—' }} + commission at the band's start +
+
+ + @if (compareFields) { +
+ {{ changedCount ? 'compare_arrows' : 'info' }} + @if (changedCount) { + + {{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }} will change on approval - shown below + as current → proposed. + + } @else { + This edit changes nothing - every term matches the slab as it stands. + } +
+ } + + @for (group of fieldGroups; track group.name) { +
+
+ {{ group.icon }} + {{ group.name }} + @if (group.changed) { + {{ group.changed }} changed + } +
+
+ @for (field of group.fields; track field.key) { +
+ + {{ field.label }} + @if (field.changed) { + Changed + } + + @if (field.changed) { + + {{ field.current || '—' }} + arrow_forward + {{ field.value || '—' }} + + } @else { + {{ field.value || '—' }} + } +
+ } +
+
+ } + + @if (slabChart.length) { +
+
+ table_rows + {{ valueOf('slabType') || 'Commission' }} slab chart + {{ + entry.status === 'PENDING' ? 'as it will read once approved' : 'as it reads now' + }} +
+ @if (slabOverlaps) { +
+ warning + + This slab's band overlaps + {{ slabOverlaps }} other slab{{ slabOverlaps !== 1 ? 's' : '' }} - a collection in the overlap + would match more than one rate. + +
+ } +
+
+ # + Collection from + Collection to + Commission +
+ @for (slab of slabChart; track $index) { +
+ + {{ slab.id ? slab.id : 'New' }} + @if (slab.requested) { + {{ isEdit ? 'Edited' : 'New' }} + } + @if (slab.overlaps) { + Overlaps + } + + + @if (slab.was && !same(slab.was.from, slab.from)) { + {{ rupees(slab.was.from) }} + } + {{ rupees(slab.from) }} + + + @if (slab.was && !same(slab.was.to, slab.to)) { + {{ rupees(slab.was.to) }} + } + {{ rupees(slab.to) }} + + + @if (slab.was && !same(slab.was.rate, slab.rate)) { + {{ percent(slab.was.rate) }} + } + {{ percent(slab.rate) }} + +
+ } +
+
+ } } @else if (isAgentReview) { - @if (partyId && !isClosed && canCloseEarly) { - - } - @if (partyId && !isClosed) { - - } @if (selectedTabIndex > 0) {
@@ -53,57 +61,432 @@
} -
-
-
-
-
{{ activeCount | number }}
-
Open Accounts
+ + @if (loaded && totalCount > 0) { + @if (!selectedAgent) { +
+
+ + + {{ agentGroups.length }} + Total agents + +
+
+ + + {{ book.accounts }} + Total DDS accounts + +
+
+ + + {{ book.open }} + Active accounts + +
+
+ + + {{ book.matured }} + Matured accounts + {{ book.maturedBalance | number: '1.2-2' }} to pay out + +
+
+ + + {{ book.closed }} + Closed accounts + {{ book.prematureClosed }} premature · {{ book.closed - book.prematureClosed }} at maturity + +
+
+ + + {{ book.balance | number: '1.2-2' }} + Total deposits held + +
-
-
-
-
-
{{ totalDailyDue | number: '1.2-2' }}
-
Daily Collection Due
+ } @else if (agentLoading) { + +
+ + Loading {{ selectedAgent.agentName }}'s accounts…
-
-
-
-
-
{{ totalCurrentBalance | number: '1.2-2' }}
-
Total Deposits Held
+ } @else { + +

Accounts

+
+ + + +
-
-
+ +

Maturity

+
+ + + + +
+ +

Money

+
+ + + + +
+ +

Collection

+
+
+ + + + + +
+
+
{{ collectionRate }}% of what was due till today has come in.
+
+ +
+ By product + @for (product of figures.byProduct; track product.name) { + {{ product.name }} {{ product.count }} + } +
+ } + }
-
+
+ @if (selectedAgent) { + +
+ + + + + {{ selectedAgent.agentName }} + {{ selectedAgent.agentCode }} + +
+ }
@if (totalCount === 0) {
-

No parties yet

+

No DDS accounts yet

Open one so an agent can start collecting daily deposits against it.

+ } @else if (!selectedAgent) { + +
+ @for (group of pagedAgents; track group.agentCode) { + + } @empty { +
+ + No agents match your search. +
+ } +
+ @if (visibleAgents.length > 0) { + + } } @else { -
+ @if (cardView !== 'all') { +
+ + Showing {{ viewLabel }} · {{ dataSource.data.length }} + {{ dataSource.data.length === 1 ? 'account' : 'accounts' }} + +
+ } +
@@ -175,11 +558,22 @@ + + + + + @@ -204,16 +598,29 @@ - @@ -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)" >
Party Code Schedule + {{ scheduleOf(p).label }} + Status - - {{ p.status === 'CLOSED' ? 'Closed' : 'Active' }} + + {{ statusLabel(p.status) }} Actions + + + @if (!isClosed(p)) { + + }
- @if (filteredCount === 0 && totalCount > 0) { + @if (dataSource.filteredData.length === 0 && totalCount > 0) {
- No parties match your filter. + No accounts of {{ selectedAgent.agentName }} match your search.
} - +
}
diff --git a/src/app/deposits/party-master/party-master.component.scss b/src/app/deposits/party-master/party-master.component.scss index b235f0f44..06a1b473e 100644 --- a/src/app/deposits/party-master/party-master.component.scss +++ b/src/app/deposits/party-master/party-master.component.scss @@ -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; +} diff --git a/src/app/deposits/party-master/party-master.component.ts b/src/app/deposits/party-master/party-master.component.ts index 7ec5f2c8a..a738a0d11 100644 --- a/src/app/deposits/party-master/party-master.component.ts +++ b/src/app/deposits/party-master/party-master.component.ts @@ -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 = { + 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 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; + + /** 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 = new MatTableDataSource([]); - 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(); + 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' }; } } diff --git a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts index 0ca81b9c1..28a1c3238 100644 --- a/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts +++ b/src/app/deposits/party-master/payout-dialog/payout-dialog.component.ts @@ -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 diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html index 7fc420782..cff4b29bb 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -6,170 +6,358 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

Close {{ data.partyCode }} early

+ +
+ +
+

Premature Closure

+ {{ data.partyCode }} · {{ data.partyName }} +
+
+ Active + + Premature Closed +
+
- + @if (loading) { - + } -
-
- Customer - {{ data.partyName }} +
+
+ Balance + ₹{{ data.currentBalance | number: '1.2-2' }}
-
- Balance - {{ data.currentBalance | number: '1.2-2' }} +
+ Scheme rate + {{ data.interestRate | number: '1.0-2' }}%
-
- Matures on - {{ data.maturityDate | dateFormat }} +
+ Opened + {{ data.openingDate | dateFormat }}
-
- Scheme rate - {{ data.interestRate | number: '1.0-2' }}% +
+ Matures on + {{ data.maturityDate | dateFormat }}
-

Who bears the cost of closing early?

- - -