feat(dds): upgrade DDS UI - agent, party, daily entry, approvals, product view

- Agent master: tabbed layout, customer search dialog, GL dropdown, account card, view page
- Party master: tabbed layout, expected party code, nominee cards, relation from code values
- Daily transaction entry: compact style, collections popup, Apply DDS Receipt placement
- Approvals: new review modal, Agent Approval tab, DDS routing into product/pending account
- DDS product: view page and list buttons; drop Auto Interest Post Day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 10:50:03 +05:30
co-authored by Claude Sonnet 5.5
parent 357f666421
commit 293b671966
45 changed files with 5973 additions and 1292 deletions
@@ -0,0 +1,125 @@
<!--
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/.
-->
<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>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" 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" [class.status-badge--delete]="row.action === 'CLOSE'">{{
requestLabel(row)
}}</span>
</td>
</ng-container>
<ng-container matColumnDef="agent">
<th mat-header-cell *matHeaderCellDef>Agent</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.agentName || '—' }}</strong>
<!-- A new agent has no code until it is approved. -->
<div class="cell-mono">{{ row.agentCode || 'Code on approval' }}</div>
</td>
</ng-container>
<ng-container matColumnDef="client">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let row">{{ row.clientName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="branch">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let row">{{ row.officeName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="savingAccount">
<th mat-header-cell *matHeaderCellDef>Saving A/C</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.savingAccountNo || '—' }}</td>
</ng-container>
<ng-container matColumnDef="guarantors">
<th mat-header-cell *matHeaderCellDef>Guarantors</th>
<td mat-cell *matCellDef="let row">{{ row.guarantorCount ?? 0 }}</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">
<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>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<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>
</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>
}
</div>
@@ -0,0 +1,30 @@
/**
* 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/.
*/
// What the Agent Approval tab adds to the Product Approval sheet it shares.
// View, Approve and Reject sit on one line: the column takes the width its buttons need and the
// other columns - whose names and addresses can wrap - give way.
.aa-actions {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
white-space: nowrap;
.psp-btn {
flex: 0 0 auto;
margin-right: 0;
}
}
th.mat-column-actions,
td.mat-column-actions {
width: 1%;
white-space: nowrap;
}
@@ -0,0 +1,175 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
import { ddsMadeOn } from '../../product-entities';
/**
* Approvals › Agent Approval: every DDS agent request waiting on a checker - an agent being opened,
* changed or closed.
*
* 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
* commission goes into, how many guarantors - and opens the same review the DDS queue uses, which
* shows the agent and their guarantors in full and approves or rejects through the DDS queue.
*/
@Component({
selector: 'mifosx-agent-approvals',
templateUrl: './agent-approvals.component.html',
// The same look as Product Approval, so the tabs of this screen read alike.
styleUrls: [
'../pending-savings-products/pending-savings-products.component.scss',
'./agent-approvals.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class AgentApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private readonly currentUserId = this.authenticationService.getCredentials()?.userId;
dataSource = new MatTableDataSource<any>([]);
loading = true;
displayedColumns: string[] = [
'request',
'agent',
'client',
'branch',
'savingAccount',
'guarantors',
'maker',
'madeOn',
'status',
'actions'
];
ngOnInit(): void {
this.dataSource.filterPredicate = (row: any, filter: string) =>
[
row.agentName,
row.agentCode,
row.clientName,
row.officeName,
row.savingAccountNo,
row.type,
row.maker,
row.makerName
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(filter);
this.load();
}
load(): void {
this.loading = true;
this.organizationService
.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) }));
this.loading = false;
});
}
applyFilter(value: string = ''): void {
this.dataSource.filter = value.trim().toLowerCase();
}
/** What the request is, as the badge reads it. */
requestLabel(row: any): string {
return row.action === 'CREATE' ? 'New' : row.action === 'CLOSE' ? 'Close' : 'Edit';
}
isMine(row: any): boolean {
return !!this.currentUserId && Number(row.makerId) === Number(this.currentUserId);
}
formatDate(date: Date | null): string {
return date ? date.toLocaleString('en-IN') : '';
}
/** Opens the agent in full - the client, address, branch, accounts and guarantors the request carries. */
view(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row, currentUserId: this.currentUserId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.load();
}
});
}
/**
* Whether this user may approve or reject the request. It is the same act the review and DDS
* Approvals perform, through the DDS queue, which refuses whoever made the request - so it is not
* offered to them here either.
*/
canAct(row: any): boolean {
return !this.isMine(row);
}
/**
* Approve and Reject open the agent in full rather than acting at once: a request is never actioned
* without its client, address, branch, accounts and guarantors on screen. The review carries the
* Approve and Reject buttons, and asks before rejecting.
*/
approve(row: any): void {
this.view(row);
}
reject(row: any): void {
this.view(row);
}
}
@@ -40,7 +40,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isProductEntity, isTempTableAccountEdit } from '../../product-entities';
import { isDdsRoutedRequest, isProductEntity, isTempTableAccountEdit } from '../../product-entities';
@Component({
selector: 'mifosx-checker-inbox',
@@ -118,7 +118,11 @@ export class CheckerInboxComponent implements OnInit {
*/
private excludeRoutedElsewhere(items: any[]): any[] {
return (items || []).filter(
(item: any) => !isProductEntity(item?.entityName) && !isTempTableAccountEdit(item?.entityName, item?.actionName)
(item: any) =>
!isProductEntity(item?.entityName) &&
!isTempTableAccountEdit(item?.entityName, item?.actionName) &&
// DDS product and account requests are listed under Product Approval and Pending Account.
!isDdsRoutedRequest(item?.entityName, item?.actionName)
);
}
@@ -61,7 +61,10 @@
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let account" class="cell-mono">{{ account.accountNo }}</td>
<!-- A new DDS account has no number until it is approved. -->
<td mat-cell *matCellDef="let account" class="cell-mono">
{{ account.accountNo || (account.source === 'DDS' ? 'On approval' : '') }}
</td>
</ng-container>
<ng-container matColumnDef="clientName">
@@ -35,6 +35,9 @@ import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.componen
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({
selector: 'mifosx-pending-account',
@@ -64,6 +67,8 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private organizationService = inject(OrganizationService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -110,7 +115,27 @@ export class PendingAccountComponent {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** A DDS account opening or edit, which waits in - and is actioned through - the DDS approval queue. */
private isDds(account: any): boolean {
return account?.source === 'DDS';
}
navigateToAccount(account: any): void {
if (this.isDds(account)) {
// A new DDS account has no page until it is approved, so it opens the DDS review instead.
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: account.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
return;
}
const clientId = account.clientId;
const accountId = account.id;
const depositTypeId = account.depositType?.id;
@@ -190,7 +215,9 @@ export class PendingAccountComponent {
selected.forEach((account: any) => {
const depositTypeId = account.depositType?.id;
let reject$;
if (account.accountType === 'Current') {
if (this.isDds(account)) {
reject$ = this.organizationService.actOnDdsApproval(account.id, 'reject');
} else if (account.accountType === 'Current') {
reject$ = this.tasksService.rejectCurrentAccount(account.id, payload);
} else if (depositTypeId === 200) {
reject$ = this.tasksService.rejectFDAccount(account.id, payload);
@@ -223,6 +250,10 @@ export class PendingAccountComponent {
* approving from inside an individual account (see e.g. ApproveSavingsAccountComponent).
*/
private approveAndActivate$(account: any, approvedOnDate: string, dateFormat: string, locale: string) {
// A DDS account opens active once its request is approved; there is no separate activation step.
if (this.isDds(account)) {
return this.organizationService.actOnDdsApproval(account.id, 'approve');
}
const approvePayload = { approvedOnDate, dateFormat, locale };
const activatePayload = { activatedOnDate: approvedOnDate, dateFormat, locale };
const depositTypeId = account.depositType?.id;
@@ -35,6 +35,8 @@ import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { productTypeDisplayLabel } from '../../product-entities';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from 'app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component';
@Component({
selector: 'mifosx-pending-savings-products',
@@ -62,6 +64,7 @@ export class PendingSavingsProductsComponent {
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
private tasksService = inject(TasksService);
private authenticationService = inject(AuthenticationService);
dataSource: MatTableDataSource<any>;
showData = false;
@@ -173,6 +176,10 @@ export class PendingSavingsProductsComponent {
* renders the submitted payload and can action the entry itself.
*/
viewDiff(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) {
this.dialog
.open(ViewCheckerInboxComponent, {
@@ -212,7 +219,35 @@ export class PendingSavingsProductsComponent {
this.loadingDiffFor = null;
}
/** A DDS product request, which is read and actioned through the DDS approval queue. */
isDds(row: any): boolean {
return row?.source === 'DDS';
}
/**
* The DDS review shows the product's terms - against today's on an edit - and approves or rejects
* it there, refusing the maker as the DDS queue does. Approve and Reject on the row open it too, so
* a DDS product is never actioned without its terms on screen.
*/
private openDdsReview(row: any): void {
this.dialog
.open(DdsApprovalReviewComponent, {
data: { entry: row.ddsEntry, currentUserId: this.authenticationService.getCredentials()?.userId },
autoFocus: false
})
.afterClosed()
.subscribe((done: boolean) => {
if (done) {
this.reload();
}
});
}
approve(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
const isEdit = this.isPendingEdit(row);
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
@@ -234,6 +269,10 @@ export class PendingSavingsProductsComponent {
}
reject(row: any): void {
if (this.isDds(row)) {
this.openDdsReview(row);
return;
}
if (!this.isPendingEdit(row)) {
// The native /makercheckers reject endpoint takes no payload, so there is nowhere to store a
// reason - ask for plain confirmation rather than collecting text that would be discarded.