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
@@ -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',