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.
|
||||
|
||||
@@ -271,6 +271,13 @@
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
|
||||
}
|
||||
</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>
|
||||
@if (count('agent-approvals')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('agent-approvals') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
routerLink="./chequebook-requests"
|
||||
|
||||
@@ -10,17 +10,21 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { Resolve } from '@angular/router';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { TasksService } from '../tasks.service';
|
||||
import { ddsMadeOn, isDdsAccountEntry } from '../product-entities';
|
||||
|
||||
@Injectable()
|
||||
export class GetPendingAccounts implements Resolve<any> {
|
||||
private tasksService = inject(TasksService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
resolve(): Observable<any> {
|
||||
return forkJoin({
|
||||
savings: this.tasksService.getPendingSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))),
|
||||
current: this.tasksService.getPendingCurrentAccounts().pipe(catchError(() => of([]))),
|
||||
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([])))
|
||||
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([]))),
|
||||
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
|
||||
}).pipe(
|
||||
map((results) => {
|
||||
const officeMap: Record<number, string> = {};
|
||||
@@ -39,9 +43,34 @@ export class GetPendingAccounts implements Resolve<any> {
|
||||
(a: any) => ({ ...a, accountType: 'Current' })
|
||||
);
|
||||
|
||||
/*
|
||||
* DDS account openings and edits wait in the DDS approval queue rather than as submitted
|
||||
* accounts, so they are laid onto the same columns here. The status filter in the component
|
||||
* reads status.id 100, which these carry so they are kept like any other pending account.
|
||||
*/
|
||||
const ddsItems = (Array.isArray(results.dds) ? results.dds : [])
|
||||
.filter((entry: any) => isDdsAccountEntry(entry))
|
||||
.map((entry: any) => ({
|
||||
id: entry.id,
|
||||
source: 'DDS',
|
||||
ddsEntry: entry,
|
||||
accountNo: entry.accountNo || '',
|
||||
clientName: entry.clientName || entry.details,
|
||||
productName: entry.productName,
|
||||
accountType: entry.action === 'UPDATE' ? 'DDS · Edit' : 'DDS',
|
||||
officeName: entry.officeName || '',
|
||||
fieldOfficerName: entry.agentName || '',
|
||||
timeline: { submittedOnDate: ddsMadeOn(entry.madeOn) },
|
||||
status: {
|
||||
id: 100,
|
||||
value: entry.action === 'UPDATE' ? 'Edit pending approval' : 'Submitted and pending approval'
|
||||
}
|
||||
}));
|
||||
|
||||
return { pageItems: [
|
||||
...savingsItems,
|
||||
...currentItems
|
||||
...currentItems,
|
||||
...ddsItems
|
||||
] };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -11,8 +11,15 @@ import { Resolve } from '@angular/router';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { TasksService } from '../tasks.service';
|
||||
import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } from '../product-entities';
|
||||
import {
|
||||
ddsMadeOn,
|
||||
isDdsProductEntry,
|
||||
isProductEntity,
|
||||
isTempTableProductEdit,
|
||||
PRODUCT_ENTITY_TYPE_LABEL
|
||||
} from '../product-entities';
|
||||
|
||||
/**
|
||||
* Builds the Approvals -> Product Approval list from the two pending-request sources the platform
|
||||
@@ -32,6 +39,7 @@ import { isProductEntity, isTempTableProductEdit, PRODUCT_ENTITY_TYPE_LABEL } fr
|
||||
export class GetPendingSavingsProducts implements Resolve<any> {
|
||||
private productsService = inject(ProductsService);
|
||||
private tasksService = inject(TasksService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
resolve(): Observable<any> {
|
||||
return forkJoin({
|
||||
@@ -42,7 +50,8 @@ export class GetPendingSavingsProducts implements Resolve<any> {
|
||||
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([]))),
|
||||
// includeJson so a CREATE row - which has no resourceId, and therefore no product to look up -
|
||||
// can still show the submitted product name.
|
||||
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([])))
|
||||
makerChecker: this.tasksService.getMakerCheckerData({ includeJson: true }).pipe(catchError(() => of([]))),
|
||||
dds: this.organizationService.getDdsApprovals('PENDING').pipe(catchError(() => of([])))
|
||||
}).pipe(
|
||||
map((results) => [
|
||||
...this.asEditRows(results.savings, 'SAVINGS'),
|
||||
@@ -50,11 +59,31 @@ export class GetPendingSavingsProducts implements Resolve<any> {
|
||||
...this.asEditRows(results.share, 'SHARE'),
|
||||
...this.asEditRows(results.fd, 'FD'),
|
||||
...this.asEditRows(results.rd, 'RD'),
|
||||
...this.asMakerCheckerRows(results.makerChecker)
|
||||
...this.asMakerCheckerRows(results.makerChecker),
|
||||
...this.asDdsRows(results.dds)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** DDS product requests, from the DDS queue - which is also how they are viewed and actioned. */
|
||||
private asDdsRows(entries: any): any[] {
|
||||
return (Array.isArray(entries) ? entries : [])
|
||||
.filter((entry: any) => isDdsProductEntry(entry))
|
||||
.map((entry: any) => ({
|
||||
id: entry.id,
|
||||
source: 'DDS',
|
||||
entityName: entry.entity,
|
||||
actionName: entry.action,
|
||||
productType: 'DDS',
|
||||
savingsProductId: entry.resourceId ?? null,
|
||||
savingsProductName: entry.details || entry.type,
|
||||
makerUsername: entry.maker,
|
||||
createdOn: ddsMadeOn(entry.madeOn),
|
||||
status: 'PENDING',
|
||||
ddsEntry: entry
|
||||
}));
|
||||
}
|
||||
|
||||
/** Staged product edits, unchanged apart from the source/action tags the merged table needs. */
|
||||
private asEditRows(edits: any[], productType: string): any[] {
|
||||
return (edits || []).map((edit: any) => ({
|
||||
|
||||
@@ -56,6 +56,53 @@ export const TEMP_TABLE_ACCOUNT_ENTITIES: readonly string[] = [
|
||||
'CURRENTACCOUNT'
|
||||
];
|
||||
|
||||
/**
|
||||
* DDS keeps its own approval queue (/dds/approvals), which also describes each entry in the clerk's
|
||||
* terms. Its product and account requests are listed in Product Approval and Pending Account as well,
|
||||
* so a checker finds them where every other product and account waits - but they are read from, and
|
||||
* actioned through, that DDS queue rather than /makercheckers.
|
||||
*/
|
||||
export function isDdsProductEntry(entry: any): boolean {
|
||||
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'DDSACCOUNTTYPE';
|
||||
}
|
||||
|
||||
export function isDdsAccountEntry(entry: any): boolean {
|
||||
return isDdsRoutedRequest(entry?.entity, entry?.action) && entry?.entity === 'PARTY';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a command is a DDS product or DDS account create/edit - one listed under Product Approval or
|
||||
* Pending Account, and so left out of DDS Approvals and the Checker Inbox, which would otherwise show
|
||||
* the same request a second and third time.
|
||||
*/
|
||||
export function isDdsRoutedRequest(
|
||||
entityName: string | undefined | null,
|
||||
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') {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
'DDSACCOUNTTYPE',
|
||||
'PARTY'
|
||||
].includes(entity) && [
|
||||
'CREATE',
|
||||
'UPDATE'
|
||||
].includes((actionName ?? '').toUpperCase());
|
||||
}
|
||||
|
||||
/** The DDS queue's "2026-10-27 11:04" as a Date, for the tables' date columns. */
|
||||
export function ddsMadeOn(madeOn: string | undefined | null): Date | null {
|
||||
if (!madeOn) {
|
||||
return null;
|
||||
}
|
||||
const d = new Date(String(madeOn).replace(' ', 'T'));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** Short type badge shown in the Product Approval table, matching the pending-edit productType tags. */
|
||||
export const PRODUCT_ENTITY_TYPE_LABEL: Record<string, string> = {
|
||||
LOANPRODUCT: 'LOAN',
|
||||
@@ -111,7 +158,8 @@ export const PRODUCT_TYPE_DISPLAY_LABEL: Record<string, string> = {
|
||||
FD: 'FIXED DEPOSIT PRODUCT',
|
||||
'FIXED DEPOSIT': 'FIXED DEPOSIT PRODUCT',
|
||||
RD: 'RECURRING DEPOSIT PRODUCT',
|
||||
'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT'
|
||||
'RECURRING DEPOSIT': 'RECURRING DEPOSIT PRODUCT',
|
||||
DDS: 'DDS PRODUCT'
|
||||
};
|
||||
|
||||
/** Label shown in the Product Approval Type column. Unmapped types render unchanged. */
|
||||
|
||||
@@ -56,6 +56,7 @@ import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurr
|
||||
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
|
||||
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
|
||||
import { DdsApprovalsComponent } from '../deposits/dds-approvals/dds-approvals.component';
|
||||
import { AgentApprovalsComponent } from './checker-inbox-and-tasks-tabs/agent-approvals/agent-approvals.component';
|
||||
|
||||
/** Tasks Routes */
|
||||
const routes: Routes = [
|
||||
@@ -235,6 +236,11 @@ const routes: Routes = [
|
||||
component: DdsApprovalsComponent,
|
||||
data: { title: 'DDS Approvals' }
|
||||
},
|
||||
{
|
||||
path: 'agent-approvals',
|
||||
component: AgentApprovalsComponent,
|
||||
data: { title: 'Agent Approval' }
|
||||
},
|
||||
{
|
||||
path: 'chequebook-requests',
|
||||
component: ChequebookRequestsComponent,
|
||||
|
||||
Reference in New Issue
Block a user