Merge branch 'maker-checker' into dev
Brings in the UI side of maker-checker for users and roles: a Pending User Edits tab with a compare dialog, user/role create-edit-view wired to the pending-request flow, plus keyboard shortcuts and inline-create dialogs. Both conflicts resolved in dev's favour: .env.ts — auto-generated build stamp; dev's (260901) is the newer one. share-product-general-tab.component.html — dev had deliberately removed the per-product Dividends button, leaving a comment explaining that dividends are declared society-wide from Share Products -> Dividends because the board resolves one rate for the year rather than one per product. The branch simply predates that change, so taking its side would have resurrected a button that was removed on purpose. checker-inbox-and-tasks.component.html and tasks-routing.module.ts auto-merged cleanly, with both the new Pending User Edits tab and the existing Closure Requests tab intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -123,7 +123,9 @@ export class CheckerInboxComponent implements OnInit {
|
||||
];
|
||||
|
||||
private excludeProductEdits(items: any[]): any[] {
|
||||
return (items || []).filter((item: any) => !this.productEditEntities.includes(item?.entityName));
|
||||
return (items || []).filter(
|
||||
(item: any) => !(this.productEditEntities.includes(item?.entityName) && item?.actionName === 'UPDATE')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<!--
|
||||
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="pce-tab-content">
|
||||
<div class="pce-action-bar">
|
||||
<div class="pce-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input #filterInput type="text" placeholder="Search by username" (keyup.enter)="applyFilter(filterInput.value)" />
|
||||
</div>
|
||||
|
||||
<select class="pce-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="pce-preset-chip"
|
||||
[class.pce-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" class="pce-btn pce-btn-refresh" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||
<p class="empty-title">No {{ filterStatus | lowercase }} user requests</p>
|
||||
<p class="empty-text">No pending user operations found for this filter.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="requests" class="modern-table">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>#</th>
|
||||
<td mat-cell *matCellDef="let req">{{ req.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="requestType">
|
||||
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||
<td mat-cell *matCellDef="let req">
|
||||
<span
|
||||
class="type-badge"
|
||||
[class.type-create]="req.requestType === 'CREATE_USER'"
|
||||
[class.type-update]="req.requestType === 'UPDATE_USER'"
|
||||
>
|
||||
{{ req.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="username">
|
||||
<th mat-header-cell *matHeaderCellDef>Username</th>
|
||||
<td mat-cell *matCellDef="let req" class="font-medium">{{ req.username || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fullname">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let req">{{ req.firstname || '' }} {{ req.lastname || '' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef>Email</th>
|
||||
<td mat-cell *matCellDef="let req">{{ req.email || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="office">
|
||||
<th mat-header-cell *matHeaderCellDef>Office ID</th>
|
||||
<td mat-cell *matCellDef="let req">{{ req.officeId || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerUsername">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let req">{{ req.makerUsername }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="submittedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let req">{{ formatDate(req.submittedDate) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let req">
|
||||
<span class="status-badge" [ngClass]="statusClass(req.status)">{{ req.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let req">
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-action--compare"
|
||||
(click)="compare(req)"
|
||||
title="View / Compare Details"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
<span>View</span>
|
||||
</button>
|
||||
|
||||
@if (req.status === 'PENDING') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-action--approve"
|
||||
(click)="approve(req)"
|
||||
title="Approve User Request"
|
||||
>
|
||||
<fa-icon icon="check"></fa-icon>
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-action--reject"
|
||||
(click)="reject(req)"
|
||||
title="Reject User Request"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
<span>Reject</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[10, 20, 50]"
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.pce-tab-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pce-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pce-search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
gap: 6px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-filter-select {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.pce-fb-sep {
|
||||
height: 24px;
|
||||
width: 1px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.pce-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pce-preset-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&--active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-refresh {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.type-create {
|
||||
background-color: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&.type-update {
|
||||
background-color: #f3e8ff;
|
||||
color: #7e22ce;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&--compare {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-color: #cbd5e1;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
&--approve {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border-color: #a7f3d0;
|
||||
|
||||
&:hover {
|
||||
background: #d1fae5;
|
||||
}
|
||||
}
|
||||
|
||||
&--reject {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #64748b;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.88rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
import { UsersService } from 'app/users/users.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import {
|
||||
UserEditCompareDialogComponent,
|
||||
UserEditCompareRow,
|
||||
UserEditCompareDialogData
|
||||
} from './user-edit-compare-dialog/user-edit-compare-dialog.component';
|
||||
|
||||
const USER_FIELD_LABELS: Record<string, string> = {
|
||||
username: 'Username',
|
||||
firstname: 'First Name',
|
||||
lastname: 'Last Name',
|
||||
email: 'Email Address',
|
||||
mobileNo: 'Mobile Number',
|
||||
officeId: 'Office',
|
||||
officeName: 'Office Name',
|
||||
staffId: 'Staff Member',
|
||||
roles: 'Assigned Roles',
|
||||
roleNames: 'Assigned Roles',
|
||||
otpDeliveryMethod: '2FA / OTP Channel',
|
||||
sendPasswordToEmail: 'Send Password To Email',
|
||||
passwordNeverExpires: 'Password Never Expires',
|
||||
password: 'Password'
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-pending-user-edits',
|
||||
templateUrl: './pending-user-edits.component.html',
|
||||
styleUrls: ['./pending-user-edits.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class PendingUserEditsComponent implements OnInit {
|
||||
private usersService = inject(UsersService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
requests: any[] = [];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
filterUsername = '';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate = new Date();
|
||||
activePreset = 'all';
|
||||
|
||||
pageIndex = 0;
|
||||
pageSize = 20;
|
||||
totalRecords = 0;
|
||||
|
||||
displayedColumns = [
|
||||
'id',
|
||||
'requestType',
|
||||
'username',
|
||||
'fullname',
|
||||
'email',
|
||||
'office',
|
||||
'makerUsername',
|
||||
'submittedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
statusOptions = [
|
||||
{ value: 'PENDING', label: 'Pending Approval' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
{ value: '', label: 'All Statuses' }
|
||||
];
|
||||
|
||||
datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: '7d', label: 'Last 7 Days' },
|
||||
{ key: '30d', label: 'Last 30 Days' },
|
||||
{ key: 'all', label: 'All Dates' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
const fromStr = this.fromDate ? this.formatIsoDate(this.fromDate) : undefined;
|
||||
const toStr = this.toDate ? this.formatIsoDate(this.toDate) : undefined;
|
||||
const offset = this.pageIndex * this.pageSize;
|
||||
|
||||
this.usersService
|
||||
.getPendingUsers(this.filterStatus, this.filterUsername, fromStr, toStr, offset, this.pageSize)
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.requests = response.pageItems || [];
|
||||
this.totalRecords = response.totalFilteredRecords || this.requests.length;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.requests = [];
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(query: string): void {
|
||||
this.filterUsername = query;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const now = new Date();
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
this.toDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
} else if (key === '7d') {
|
||||
const past = new Date();
|
||||
past.setDate(past.getDate() - 7);
|
||||
this.fromDate = past;
|
||||
this.toDate = now;
|
||||
} else if (key === '30d') {
|
||||
const past = new Date();
|
||||
past.setDate(past.getDate() - 30);
|
||||
this.fromDate = past;
|
||||
this.toDate = now;
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.pageSize = event.pageSize;
|
||||
this.load();
|
||||
}
|
||||
|
||||
formatDate(val: any): string {
|
||||
if (!val) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(val);
|
||||
return isNaN(d.getTime()) ? String(val) : d.toLocaleString();
|
||||
}
|
||||
|
||||
private formatIsoDate(d: Date): string {
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
if (status === 'PENDING') {
|
||||
return 'status-badge--pending';
|
||||
}
|
||||
if (status === 'APPROVED') {
|
||||
return 'status-badge--approved';
|
||||
}
|
||||
if (status === 'REJECTED') {
|
||||
return 'status-badge--rejected';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
compare(req: any): void {
|
||||
this.usersService.getPendingUser(req.id).subscribe((detail: any) => {
|
||||
const rows: UserEditCompareRow[] = [];
|
||||
const proposed = detail.proposedValues || {};
|
||||
const original = detail.originalValues || {};
|
||||
|
||||
const allKeys = Array.from(
|
||||
new Set([
|
||||
...Object.keys(original),
|
||||
...Object.keys(proposed)
|
||||
])
|
||||
);
|
||||
for (const k of allKeys) {
|
||||
if (k === 'repeatPassword' || k === 'pendingLockKey' || k === 'id') {
|
||||
continue;
|
||||
}
|
||||
const label = USER_FIELD_LABELS[k] || k;
|
||||
let curVal = original[k];
|
||||
let propVal = proposed[k];
|
||||
|
||||
if (Array.isArray(curVal)) {
|
||||
curVal = curVal.join(', ');
|
||||
}
|
||||
if (Array.isArray(propVal)) {
|
||||
propVal = propVal.join(', ');
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: k,
|
||||
label,
|
||||
current: curVal,
|
||||
value: propVal
|
||||
});
|
||||
}
|
||||
|
||||
const data: UserEditCompareDialogData = {
|
||||
requestId: detail.id,
|
||||
requestType: detail.requestType,
|
||||
username: detail.username || req.username,
|
||||
rows,
|
||||
status: detail.status,
|
||||
makerUsername: detail.makerUsername,
|
||||
submittedDate: this.formatDate(detail.submittedDate),
|
||||
checkerUsername: detail.checkerUsername,
|
||||
checkedDate: this.formatDate(detail.checkedDate),
|
||||
rejectReason: detail.rejectReason
|
||||
};
|
||||
|
||||
this.dialog.open(UserEditCompareDialogComponent, {
|
||||
data,
|
||||
width: '650px'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
approve(req: any): void {
|
||||
const actionLabel = req.requestType === 'CREATE_USER' ? 'creation' : 'update';
|
||||
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: `Approve User ${actionLabel}`,
|
||||
dialogContext: `Are you sure you want to approve this user ${actionLabel} for '${req.username}'?`
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
this.usersService.approvePendingUser(req.id).subscribe(() => {
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(req: any): void {
|
||||
const formfields: FormfieldBase[] = [
|
||||
new InputBase({
|
||||
controlType: 'input',
|
||||
key: 'reason',
|
||||
label: 'Rejection Reason',
|
||||
required: true,
|
||||
order: 1
|
||||
})
|
||||
];
|
||||
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
title: `Reject User Request`,
|
||||
layout: { addButtonText: 'Reject' },
|
||||
formfields
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any) => {
|
||||
if (result && result.data && result.data.reason) {
|
||||
this.usersService.rejectPendingUser(req.id, result.data.reason).subscribe(() => {
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="dialog-title">
|
||||
<span class="dialog-title__action"
|
||||
>{{ data.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} Request</span
|
||||
>
|
||||
<span class="dialog-title__user">— {{ data.username }}</span>
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="dialog-body">
|
||||
<div class="meta-banner">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Request ID</span>
|
||||
<span class="meta-val">#{{ data.requestId }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Submitted By</span>
|
||||
<span class="meta-val">{{ data.makerUsername }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Submitted On</span>
|
||||
<span class="meta-val">{{ data.submittedDate }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Status</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
[class.badge-pending]="data.status === 'PENDING'"
|
||||
[class.badge-approved]="data.status === 'APPROVED'"
|
||||
[class.badge-rejected]="data.status === 'REJECTED'"
|
||||
>
|
||||
{{ data.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.rejectReason) {
|
||||
<div class="reject-banner"><strong>Rejection Reason:</strong> {{ data.rejectReason }}</div>
|
||||
}
|
||||
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
@if (data.requestType === 'UPDATE_USER') {
|
||||
<th>Current Value</th>
|
||||
}
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of data.rows; track r.key) {
|
||||
<tr>
|
||||
<td class="col-field">{{ r.label }}</td>
|
||||
@if (data.requestType === 'UPDATE_USER') {
|
||||
<td class="col-curr">{{ r.current != null && r.current !== '' ? r.current : '—' }}</td>
|
||||
}
|
||||
<td class="col-prop" [class.highlight-change]="data.requestType === 'UPDATE_USER' && r.current !== r.value">
|
||||
{{ r.value != null && r.value !== '' ? r.value : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close class="btn-close">Close</button>
|
||||
</mat-dialog-actions>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&__action {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&__user {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 20px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.meta-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.meta-val {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.badge-pending {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.badge-approved {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&.badge-rejected {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.reject-banner {
|
||||
background-color: #fff1f2;
|
||||
border: 1px solid #fecdd3;
|
||||
color: #9f1239;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.compare-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.col-field {
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.col-curr {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.col-prop {
|
||||
color: #0f172a;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight-change {
|
||||
background-color: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
color: #64748b;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface UserEditCompareRow {
|
||||
key: string;
|
||||
label: string;
|
||||
current: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface UserEditCompareDialogData {
|
||||
requestId: string | number;
|
||||
requestType: string;
|
||||
username: string;
|
||||
rows: UserEditCompareRow[];
|
||||
status?: string;
|
||||
makerUsername?: string;
|
||||
submittedDate?: string | null;
|
||||
checkerUsername?: string;
|
||||
checkedDate?: string | null;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-user-edit-compare-dialog',
|
||||
templateUrl: './user-edit-compare-dialog.component.html',
|
||||
styleUrls: ['./user-edit-compare-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class UserEditCompareDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<UserEditCompareDialogComponent>>(MatDialogRef);
|
||||
data = inject<UserEditCompareDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -119,6 +119,15 @@
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Client Edits</span>
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
routerLink="./pending-user-edits"
|
||||
routerLinkActive="ac-tab--active"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending User Approvals</span>
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
routerLink="./rejected-account"
|
||||
|
||||
@@ -36,6 +36,7 @@ import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-t
|
||||
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
||||
import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component';
|
||||
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
||||
import { PendingUserEditsComponent } from './checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component';
|
||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
@@ -168,6 +169,11 @@ const routes: Routes = [
|
||||
component: PendingClientEditsComponent,
|
||||
data: { title: 'Pending Client Edits' }
|
||||
},
|
||||
{
|
||||
path: 'pending-user-edits',
|
||||
component: PendingUserEditsComponent,
|
||||
data: { title: 'Pending User Approvals' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
component: WithdrawalRequestsComponent,
|
||||
|
||||
@@ -65,7 +65,10 @@ export class ViewCheckerInboxComponent {
|
||||
];
|
||||
|
||||
get isProductEditEntry(): boolean {
|
||||
return this.productEditEntities.includes(this.checkerInboxDetail?.entityName);
|
||||
return (
|
||||
this.productEditEntities.includes(this.checkerInboxDetail?.entityName) &&
|
||||
this.checkerInboxDetail?.actionName === 'UPDATE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user