implement user, role, product, and task management components along with associated services and UI updates

This commit is contained in:
2026-08-27 18:30:45 +05:30
parent fa2522e678
commit abc966d507
20 changed files with 1471 additions and 202 deletions
@@ -33,6 +33,7 @@ import {
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { Accounting } from 'app/core/utils/accounting';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -70,6 +71,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateFixedDepositProductComponent {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
@@ -265,14 +267,24 @@ export class CreateFixedDepositProductComponent {
locale: this.settingsService.language.code
});
delete fixedDepositProduct.advancedAccountingRules;
this.productsService.createFixedDepositProduct(fixedDepositProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createFixedDepositProduct(fixedDepositProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Fixed deposit product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
}
@@ -21,6 +21,7 @@ import { FixedDepositProductAccountingStepComponent } from '../fixed-deposit-pro
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { MatTabsModule } from '@angular/material/tabs';
import {
MatTable,
@@ -70,6 +71,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditFixedDepositProductComponent {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
@@ -280,8 +282,14 @@ export class EditFixedDepositProductComponent {
delete fixedDepositProduct.advancedAccountingRules;
this.productsService
.updateFixedDepositProduct(this.fixedDepositProductsTemplate.id, fixedDepositProduct)
.subscribe((response: any) => {
this.router.navigate(['../'], { relativeTo: this.route });
.subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: 'Fixed deposit product update submitted successfully for Checker approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
}
});
}
@@ -28,7 +28,7 @@
}
</div>
</div>
<div style="display: flex; gap: 10px; flex-shrink: 0">
<div class="pv-hero-actions">
<button
class="pv-edit-btn"
type="button"
@@ -43,111 +43,141 @@
</div>
</div>
<div class="pv-legacy">
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Details' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Short Name' | translate }}:</span>
<span class="flex-60">{{ shareProduct.shortName }}</span>
<!-- DETAILS -->
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>{{ 'labels.inputs.Details' | translate }}</h3>
</div>
@if (shareProduct.description) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Description' | translate }}:</span>
<span class="flex-60">{{ shareProduct.description }}</span>
</div>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Currency' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
<span class="flex-60">{{ shareProduct.currency.name }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
<span class="flex-60">{{ shareProduct.currency.decimalPlaces }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span>
<span class="flex-60">{{ shareProduct.currency.inMultiplesOf }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Total Number of Shares' | translate }}:</span>
<span class="flex-60">{{ shareProduct.totalShares | formatNumber }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Shares to be Issued' | translate }}:</span>
<span class="flex-60">{{ shareProduct.totalSharesIssued | formatNumber }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal/Unit Price' | translate }}:</span>
<span class="flex-60">{{ shareProduct.unitPrice | formatNumber }}</span>
</div>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Capital Value' | translate }}:</span>
<span class="flex-60">{{ shareProduct.shareCapital | formatNumber }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Settings' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Shares per Client' | translate }}:</span>
<span class="flex-60">{{
shareProduct.nominalShares +
' (Min: ' +
(shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) +
', Max: ' +
(shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) +
')'
}}</span>
</div>
@if (shareProduct.minimumActivePeriod) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Active Period' | translate }}:</span>
<span class="flex-60"
>{{ shareProduct.minimumActivePeriod }}
{{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }}</span
<div class="pv-grid">
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Short Name' | translate }}</span>
<span class="pv-v"
><strong>{{ shareProduct.shortName }}</strong></span
>
</div>
}
@if (shareProduct.description) {
<div class="pv-kv pv-kv--span">
<span class="pv-k">{{ 'labels.inputs.Description' | translate }}</span>
<span class="pv-v">{{ shareProduct.description }}</span>
</div>
}
</div>
</section>
@if (shareProduct.lockinPeriod) {
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Lock-in Period' | translate }}:</span>
<span class="flex-60"
>{{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }}</span
<!-- CURRENCY -->
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--teal"><fa-icon icon="coins"></fa-icon></div>
<h3>{{ 'labels.heading.Currency' | translate }}</h3>
</div>
<div class="pv-grid">
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="pv-v"
><strong>{{ shareProduct.currency.name }}</strong></span
>
</div>
}
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Allow dividends for inactive clients' | translate }}:</span>
<span class="flex-60">{{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }}</span>
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Decimal Places' | translate }}</span>
<span class="pv-v">{{ shareProduct.currency.decimalPlaces }}</span>
</div>
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Currency in multiples of' | translate }}</span>
<span class="pv-v">{{ shareProduct.currency.inMultiplesOf }}</span>
</div>
</div>
</section>
@if (shareProduct.marketPrice?.length) {
<div class="layout-row-wrap responsive-column flex-fill">
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Market Price Period' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="shareProduct.marketPrice">
<!-- TERMS -->
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--amber"><fa-icon icon="percentage"></fa-icon></div>
<h3>{{ 'labels.heading.Terms' | translate }}</h3>
</div>
<div class="pv-grid">
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Total Number of Shares' | translate }}</span>
<span class="pv-v">{{ shareProduct.totalShares | formatNumber }}</span>
</div>
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Shares to be Issued' | translate }}</span>
<span class="pv-v">{{ shareProduct.totalSharesIssued | formatNumber }}</span>
</div>
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Nominal/Unit Price' | translate }}</span>
<span class="pv-v">{{ shareProduct.unitPrice | formatNumber }}</span>
</div>
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Capital Value' | translate }}</span>
<span class="pv-v">{{ shareProduct.shareCapital | formatNumber }}</span>
</div>
</div>
</section>
<!-- SETTINGS -->
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--indigo"><fa-icon icon="sliders-h"></fa-icon></div>
<h3>{{ 'labels.heading.Settings' | translate }}</h3>
</div>
<div class="pv-grid">
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Shares per Client' | translate }}</span>
<span class="pv-v">{{
shareProduct.nominalShares +
' (Min: ' +
(shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) +
', Max: ' +
(shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) +
')'
}}</span>
</div>
@if (shareProduct.minimumActivePeriod) {
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Minimum Active Period' | translate }}</span>
<span class="pv-v"
>{{ shareProduct.minimumActivePeriod }}
{{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }}</span
>
</div>
}
@if (shareProduct.lockinPeriod) {
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Lock-in Period' | translate }}</span>
<span class="pv-v"
>{{ shareProduct.lockinPeriod }}
{{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }}</span
>
</div>
}
<div class="pv-kv">
<span class="pv-k">{{ 'labels.inputs.Allow dividends for inactive clients' | translate }}</span>
<span class="pv-v">
<span
class="pv-pill"
[class.pv-pill--green]="shareProduct.allowDividendCalculationForInactiveClients"
[class.pv-pill--gray]="!shareProduct.allowDividendCalculationForInactiveClients"
>
{{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }}
</span>
</span>
</div>
</div>
</section>
<!-- MARKET PRICE -->
@if (shareProduct.marketPrice?.length) {
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--teal"><fa-icon icon="chart-line"></fa-icon></div>
<h3>{{ 'labels.inputs.Market Price Period' | translate }}</h3>
</div>
<div class="pv-table-wrap">
<table class="pv-table" mat-table [dataSource]="shareProduct.marketPrice">
<ng-container matColumnDef="fromDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.From Date' | translate }}</th>
<td mat-cell *matCellDef="let marketPrice">
@@ -164,13 +194,18 @@
<tr mat-row *matRowDef="let row; columns: marketPriceDisplayedColumns"></tr>
</table>
</div>
}
</section>
}
@if (shareProduct.charges.length) {
<div class="layout-row-wrap responsive-column flex-fill">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Charges' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="shareProduct.charges">
<!-- CHARGES -->
@if (shareProduct.charges.length) {
<section class="pv-section">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--red"><fa-icon icon="receipt"></fa-icon></div>
<h3>{{ 'labels.heading.Charges' | translate }}</h3>
</div>
<div class="pv-table-wrap">
<table class="pv-table" mat-table [dataSource]="shareProduct.charges">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let charge">
@@ -199,62 +234,57 @@
<tr mat-row *matRowDef="let row; columns: chargesDisplayedColumns"></tr>
</table>
</div>
}
</section>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Accounting' | translate }}</h3>
<!-- ACCOUNTING -->
<section class="pv-section pv-accounting">
<div class="pv-section-h">
<div class="pv-section-ic pv-section-ic--green"><fa-icon icon="book"></fa-icon></div>
<h3>{{ 'labels.heading.Accounting' | translate }}</h3>
</div>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<span class="flex-40">{{ 'labels.inputs.Type' | translate }}:</span>
<span class="flex-60">{{
<div class="pv-accounting-row">
<span class="pv-accounting-label">{{ 'labels.inputs.Type' | translate }}</span>
<span class="pv-accounting-value">{{
'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate
}}</span>
</div>
@if (shareProduct.accountingRule.id === 2) {
<div class="layout-row-wrap responsive-column flex-fill">
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Share reference'"
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Share suspense control'"
[glAccount]="shareProduct.accountingMappings.shareSuspenseId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Share Equity' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Equity'"
[glAccount]="shareProduct.accountingMappings.shareEquityId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Income' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Income from fees'"
[glAccount]="shareProduct.accountingMappings.incomeFromFeeAccountId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<div class="pv-accounting-details">
<h4 class="pv-sub-h">{{ 'labels.heading.Assets' | translate }}</h4>
<mifosx-gl-account-display
[accountTitle]="'Share reference'"
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
<h4 class="pv-sub-h">{{ 'labels.heading.Liabilities' | translate }}</h4>
<mifosx-gl-account-display
[accountTitle]="'Share suspense control'"
[glAccount]="shareProduct.accountingMappings.shareSuspenseId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
<h4 class="pv-sub-h">{{ 'labels.heading.Share Equity' | translate }}</h4>
<mifosx-gl-account-display
[accountTitle]="'Equity'"
[glAccount]="shareProduct.accountingMappings.shareEquityId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
<h4 class="pv-sub-h">{{ 'labels.heading.Income' | translate }}</h4>
<mifosx-gl-account-display
[accountTitle]="'Income from fees'"
[glAccount]="shareProduct.accountingMappings.incomeFromFeeAccountId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
}
</div>
</section>
</div>
@@ -6,8 +6,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
@use 'assets/styles/product-view' as pv;
@include pv.product-view-styles;
@@ -18,13 +16,111 @@
--pv-accent-l: #fce7f3;
}
.tab-container {
padding: 1%;
margin: 1%;
.pv-hero-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
align-items: flex-start;
}
.mat-h3 {
margin-top: 10px !important;
font-weight: 300;
color: $status-approved;
/* ───────── COMPACT ACCOUNTING SECTION ───────── */
.pv-accounting {
.pv-accounting-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 14px;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px dashed var(--pv-g100);
min-height: unset;
line-height: 1.4;
}
.pv-accounting-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pv-g500);
margin: 0;
flex: none;
}
.pv-accounting-value {
font-size: 13.5px;
color: var(--pv-g900);
font-weight: 500;
word-break: break-word;
margin: 0;
flex: none;
}
.pv-accounting-details {
margin-top: 0;
h4.pv-sub-h {
margin: 16px 0 8px;
font-size: 12px;
font-weight: 700;
color: var(--pv-g700);
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.2;
}
mifosx-gl-account-display {
display: block;
padding: 8px 0;
min-height: unset;
line-height: 1.4;
border-bottom: 1px dashed var(--pv-g100);
&:last-child {
border-bottom: 0;
}
::ng-deep .layout-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 14px;
align-items: baseline;
margin: 0;
}
::ng-deep div {
margin: 0 !important;
line-height: 1.4;
}
::ng-deep .header {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pv-g500);
margin: 0 !important;
max-width: 100%;
flex: none;
}
::ng-deep div:not(.header) {
font-size: 13.5px;
color: var(--pv-g900);
font-weight: 500;
word-break: break-word;
margin: 0 !important;
max-width: 100%;
flex: none;
}
}
}
@media (width <= 600px) {
.pv-accounting-row,
.pv-accounting-details mifosx-gl-account-display ::ng-deep .layout-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
}
@@ -13,6 +13,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
/** Custom Services */
import { SystemService } from '../../system.service';
import { AlertService } from 'app/core/alert/alert.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
@@ -36,6 +37,7 @@ export class AddRoleComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private authService = inject(AuthService);
private alertService = inject(AlertService);
/** Role form. */
roleForm: UntypedFormGroup;
@@ -69,7 +71,12 @@ export class AddRoleComponent implements OnInit {
*/
submit() {
this.systemService.createRole(this.roleForm.value).subscribe((response: any) => {
if (environment.OIDC.oidcServerEnabled) {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role creation request submitted successfully for approval.'
});
} else if (environment.OIDC.oidcServerEnabled) {
this.authService.createRole(response.resourceId, this.roleForm.value.name, this.roleForm.value.description);
}
this.router.navigate(['../'], { relativeTo: this.route });
@@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { SystemService } from '../../system.service';
import { AlertService } from 'app/core/alert/alert.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
@@ -36,6 +37,7 @@ export class EditRoleComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private authService = inject(AuthService);
private alertService = inject(AlertService);
/** Role Form */
roleForm: UntypedFormGroup;
@@ -83,8 +85,13 @@ export class EditRoleComponent implements OnInit {
* if successful redirects to view updated roles and permissions.
*/
submit() {
this.systemService.updateRole(this.roleForm.value, this.roleData.id).subscribe(() => {
if (environment.OIDC.oidcServerEnabled) {
this.systemService.updateRole(this.roleForm.value, this.roleData.id).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role update request submitted successfully for approval.'
});
} else if (environment.OIDC.oidcServerEnabled) {
this.authService.updateRole(
this.roleData.id,
this.roleForm.get('name')?.value,
@@ -22,6 +22,7 @@ import { EnableDialogComponent } from '../../../shared/enable-dialog/enable-dial
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Service Zitadel */
import { environment } from '../../../../environments/environment';
@@ -48,6 +49,7 @@ export class ViewRoleComponent implements OnInit {
private translateService = inject(TranslateService);
dialog = inject(MatDialog);
private authService = inject(AuthService);
private alertService = inject(AlertService);
/** Role Permissions Data */
rolePermissionService: any;
@@ -720,7 +722,14 @@ export class ViewRoleComponent implements OnInit {
this.formGroup.controls.roster.disable();
this.checkboxesChanged = false;
this.isDisabled = true;
this.systemService.updateRolePermission(this.roleId, permissionData).subscribe((response: any) => {});
this.systemService.updateRolePermission(this.roleId, permissionData).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role permissions update request submitted successfully for approval.'
});
}
});
}
/**
@@ -756,8 +765,13 @@ export class ViewRoleComponent implements OnInit {
});
deleteRoleDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteRole(this.roleId).subscribe(() => {
if (environment.OIDC.oidcServerEnabled) {
this.systemService.deleteRole(this.roleId).subscribe((res: any) => {
if (res?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role deletion request submitted successfully for approval.'
});
} else if (environment.OIDC.oidcServerEnabled) {
this.authService.deleteRole(this.roleId);
}
this.router.navigate(['/system/roles-and-permissions']);
@@ -776,7 +790,13 @@ export class ViewRoleComponent implements OnInit {
});
enableRoleDialogRef.afterClosed().subscribe((response: any) => {
if (response.enable) {
this.systemService.enableRole(this.roleId).subscribe(() => {
this.systemService.enableRole(this.roleId).subscribe((res: any) => {
if (res?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role enablement request submitted successfully for approval.'
});
}
this.router.navigate(['/system/roles-and-permissions']);
});
} else {
@@ -793,7 +813,13 @@ export class ViewRoleComponent implements OnInit {
});
deleteRoleDialogRef.afterClosed().subscribe((response: any) => {
if (response.disable) {
this.systemService.disableRole(this.roleId).subscribe(() => {
this.systemService.disableRole(this.roleId).subscribe((res: any) => {
if (res?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Role disablement request submitted successfully for approval.'
});
}
this.router.navigate(['/system/roles-and-permissions']);
});
} else {
@@ -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&hellip;</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>
@@ -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;
}
}
@@ -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();
});
}
});
}
}
@@ -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">&mdash; {{ 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>
@@ -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;
}
@@ -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);
}
@@ -108,6 +108,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"
+6
View File
@@ -33,6 +33,7 @@ import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/cheq
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.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 */
@@ -156,6 +157,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,
@@ -21,6 +21,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { UsersService } from '../users.service';
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Dialog Component */
import { PasswordsUtility } from 'app/core/utils/passwords-utility';
@@ -56,6 +57,7 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private dialog = inject(MatDialog);
private passwordsUtility = inject(PasswordsUtility);
private alertService = inject(AlertService);
/** User form. */
userForm: UntypedFormGroup;
@@ -211,6 +213,12 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
if (this.configurationWizardService.showUsersForm) {
this.configurationWizardService.showUsersForm = false;
this.openDialog();
} else if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'User creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
+31 -21
View File
@@ -19,6 +19,7 @@ import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit User Component.
@@ -39,6 +40,7 @@ export class EditUserComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** User Data */
userData: any;
@@ -142,27 +144,35 @@ export class EditUserComponent implements OnInit {
const editedUser = this.editUserForm.value;
this.usersService.editUser(this.userData.id, editedUser).subscribe({
next: (response: any) => {
// Fineract sometimes omits resourceId in PUT responses — fall back to
// the user id we're editing so navigation never silently no-ops.
const targetId = response?.resourceId ?? this.userData.id;
const dialogRef = this.dialog.open(SuccessDialogComponent, {
data: {
title: 'User updated',
message: `Changes to ${this.userData.username} have been saved successfully.`,
buttonLabel: 'View user'
},
panelClass: 'sd-panel',
disableClose: true
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(
[
'../../',
targetId
],
{ relativeTo: this.route }
);
});
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'User update request submitted successfully for approval.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
} else {
// Fineract sometimes omits resourceId in PUT responses — fall back to
// the user id we're editing so navigation never silently no-ops.
const targetId = response?.resourceId ?? this.userData.id;
const dialogRef = this.dialog.open(SuccessDialogComponent, {
data: {
title: 'User updated',
message: `Changes to ${this.userData.username} have been saved successfully.`,
buttonLabel: 'View user'
},
panelClass: 'sd-panel',
disableClose: true
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(
[
'../../',
targetId
],
{ relativeTo: this.route }
);
});
}
},
error: (err) => {
console.error('[EditUser] update failed', err);
+58
View File
@@ -87,4 +87,62 @@ export class UsersService {
const httpParams = new HttpParams().set('officeId', officeId.toString()).set('status', 'all');
return this.http.get('/staff', { params: httpParams });
}
/**
* @param {string} status Status filter (e.g. 'PENDING')
* @param {string} username Username filter
* @param {string} fromDate From date filter
* @param {string} toDate To date filter
* @param {number} offset Offset
* @param {number} limit Limit
* @returns {Observable<any>} Pending users
*/
getPendingUsers(
status?: string,
username?: string,
fromDate?: string,
toDate?: string,
offset: number = 0,
limit: number = 20
): Observable<any> {
let httpParams = new HttpParams().set('offset', offset.toString()).set('limit', limit.toString());
if (status) {
httpParams = httpParams.set('status', status);
}
if (username) {
httpParams = httpParams.set('username', username);
}
if (fromDate) {
httpParams = httpParams.set('fromDate', fromDate);
}
if (toDate) {
httpParams = httpParams.set('toDate', toDate);
}
return this.http.get('/users/pending', { params: httpParams });
}
/**
* @param {string | number} id Pending request ID
* @returns {Observable<any>}
*/
getPendingUser(id: string | number): Observable<any> {
return this.http.get(`/users/pending/${id}`);
}
/**
* @param {string | number} id Pending request ID
* @returns {Observable<any>}
*/
approvePendingUser(id: string | number): Observable<any> {
return this.http.post(`/users/pending/${id}/approve`, {});
}
/**
* @param {string | number} id Pending request ID
* @param {string} reason Rejection reason
* @returns {Observable<any>}
*/
rejectPendingUser(id: string | number, reason: string): Observable<any> {
return this.http.post(`/users/pending/${id}/reject`, { reason });
}
}
@@ -15,6 +15,7 @@ import { MatIconButton } from '@angular/material/button';
/** Custom Services */
import { UsersService } from '../users.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Components */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
@@ -42,6 +43,7 @@ export class ViewUserComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** User Data. */
userData: any;
@@ -68,7 +70,13 @@ export class ViewUserComponent {
});
deleteUserDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.usersService.deleteUser(this.userData.id).subscribe(() => {
this.usersService.deleteUser(this.userData.id).subscribe((res: any) => {
if (res?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'User deletion request submitted successfully for approval.'
});
}
this.router.navigate(['/appusers']);
});
}
+6
View File
@@ -357,6 +357,8 @@
&.num {
text-align: right;
white-space: nowrap;
width: 1%;
}
}
@@ -369,6 +371,8 @@
&.num {
text-align: right;
white-space: nowrap;
width: 1%;
}
}
}
@@ -496,8 +500,10 @@
.pl-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 30px;
min-width: 80px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid;