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:
+125
@@ -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>
|
||||
+30
@@ -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;
|
||||
}
|
||||
+175
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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">
|
||||
|
||||
+32
-1
@@ -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;
|
||||
|
||||
+39
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user