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
@@ -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. */