diff --git a/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts b/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts index e9517e10e..30ba4bd6f 100644 --- a/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts +++ b/src/app/products/fixed-deposit-products/create-fixed-deposit-product/create-fixed-deposit-product.component.ts @@ -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 } + ); + } + } }); } } diff --git a/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts b/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts index 7bad586e3..3b9fd2f91 100644 --- a/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts +++ b/src/app/products/fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component.ts @@ -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 }); + } }); } diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html index d1f16779a..5d64cc0ba 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html @@ -28,7 +28,7 @@ } -
+
-
-

{{ 'labels.inputs.Details' | translate }}

- - - -
- {{ 'labels.inputs.Short Name' | translate }}: - {{ shareProduct.shortName }} + +
+
+
+

{{ 'labels.inputs.Details' | translate }}

- - @if (shareProduct.description) { -
- {{ 'labels.inputs.Description' | translate }}: - {{ shareProduct.description }} -
- } - -

{{ 'labels.heading.Currency' | translate }}

- - - -
- {{ 'labels.inputs.Currency' | translate }}: - {{ shareProduct.currency.name }} -
- -
- {{ 'labels.inputs.Decimal Places' | translate }}: - {{ shareProduct.currency.decimalPlaces }} -
- -
- {{ 'labels.inputs.Currency in multiples of' | translate }}: - {{ shareProduct.currency.inMultiplesOf }} -
- -

{{ 'labels.heading.Terms' | translate }}

- - - -
- {{ 'labels.inputs.Total Number of Shares' | translate }}: - {{ shareProduct.totalShares | formatNumber }} -
- -
- {{ 'labels.inputs.Shares to be Issued' | translate }}: - {{ shareProduct.totalSharesIssued | formatNumber }} -
- -
- {{ 'labels.inputs.Nominal/Unit Price' | translate }}: - {{ shareProduct.unitPrice | formatNumber }} -
- -
- {{ 'labels.inputs.Capital Value' | translate }}: - {{ shareProduct.shareCapital | formatNumber }} -
- -

{{ 'labels.heading.Settings' | translate }}

- - - -
- {{ 'labels.inputs.Shares per Client' | translate }}: - {{ - shareProduct.nominalShares + - ' (Min: ' + - (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + - ', Max: ' + - (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + - ')' - }} -
- - @if (shareProduct.minimumActivePeriod) { -
- {{ 'labels.inputs.Minimum Active Period' | translate }}: - {{ shareProduct.minimumActivePeriod }} - {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} +
+ {{ 'labels.inputs.Short Name' | translate }} + {{ shareProduct.shortName }}
- } + @if (shareProduct.description) { +
+ {{ 'labels.inputs.Description' | translate }} + {{ shareProduct.description }} +
+ } +
+
- @if (shareProduct.lockinPeriod) { -
- {{ 'labels.inputs.Lock-in Period' | translate }}: - {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} +
+
+
+

{{ 'labels.heading.Currency' | translate }}

+
+
+
+ {{ 'labels.inputs.Currency' | translate }} + {{ shareProduct.currency.name }}
- } - -
- {{ 'labels.inputs.Allow dividends for inactive clients' | translate }}: - {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} +
+ {{ 'labels.inputs.Decimal Places' | translate }} + {{ shareProduct.currency.decimalPlaces }} +
+
+ {{ 'labels.inputs.Currency in multiples of' | translate }} + {{ shareProduct.currency.inMultiplesOf }} +
+
- @if (shareProduct.marketPrice?.length) { -
-

{{ 'labels.inputs.Market Price Period' | translate }}

- - + +
+
+
+

{{ 'labels.heading.Terms' | translate }}

+
+
+
+ {{ 'labels.inputs.Total Number of Shares' | translate }} + {{ shareProduct.totalShares | formatNumber }} +
+
+ {{ 'labels.inputs.Shares to be Issued' | translate }} + {{ shareProduct.totalSharesIssued | formatNumber }} +
+
+ {{ 'labels.inputs.Nominal/Unit Price' | translate }} + {{ shareProduct.unitPrice | formatNumber }} +
+
+ {{ 'labels.inputs.Capital Value' | translate }} + {{ shareProduct.shareCapital | formatNumber }} +
+
+
+ + +
+
+
+

{{ 'labels.heading.Settings' | translate }}

+
+
+
+ {{ 'labels.inputs.Shares per Client' | translate }} + {{ + shareProduct.nominalShares + + ' (Min: ' + + (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) + + ', Max: ' + + (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) + + ')' + }} +
+ + @if (shareProduct.minimumActivePeriod) { +
+ {{ 'labels.inputs.Minimum Active Period' | translate }} + {{ shareProduct.minimumActivePeriod }} + {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }} +
+ } + + @if (shareProduct.lockinPeriod) { +
+ {{ 'labels.inputs.Lock-in Period' | translate }} + {{ shareProduct.lockinPeriod }} + {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }} +
+ } + +
+ {{ 'labels.inputs.Allow dividends for inactive clients' | translate }} + + + {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }} + + +
+
+
+ + + @if (shareProduct.marketPrice?.length) { +
+
+
+

{{ 'labels.inputs.Market Price Period' | translate }}

+
+
+
{{ 'labels.inputs.From Date' | translate }} @@ -164,13 +194,18 @@
- } + + } - @if (shareProduct.charges.length) { -
-

{{ 'labels.heading.Charges' | translate }}

- - + + @if (shareProduct.charges.length) { +
+
+
+

{{ 'labels.heading.Charges' | translate }}

+
+
+
{{ 'labels.inputs.Name' | translate }} @@ -199,62 +234,57 @@
- } + + } -

{{ 'labels.heading.Accounting' | translate }}

+ +
+
+
+

{{ 'labels.heading.Accounting' | translate }}

+
- - -
- {{ 'labels.inputs.Type' | translate }}: - {{ +
+ {{ 'labels.inputs.Type' | translate }} + {{ 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate }}
@if (shareProduct.accountingRule.id === 2) { -
-

{{ 'labels.heading.Assets' | translate }}

-
- - -
-

{{ 'labels.heading.Liabilities' | translate }}

-
- - -
-

{{ 'labels.heading.Share Equity' | translate }}

-
- - -
-

{{ 'labels.heading.Income' | translate }}

-
- - -
+
+

{{ 'labels.heading.Assets' | translate }}

+ + + +

{{ 'labels.heading.Liabilities' | translate }}

+ + + +

{{ 'labels.heading.Share Equity' | translate }}

+ + + +

{{ 'labels.heading.Income' | translate }}

+ +
} -
+
diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss index c6be20032..9dcaba7ca 100644 --- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss +++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss @@ -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; + } + } } diff --git a/src/app/system/roles-and-permissions/add-role/add-role.component.ts b/src/app/system/roles-and-permissions/add-role/add-role.component.ts index 844cf31fa..9b39f32d2 100644 --- a/src/app/system/roles-and-permissions/add-role/add-role.component.ts +++ b/src/app/system/roles-and-permissions/add-role/add-role.component.ts @@ -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 }); diff --git a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts index 668419223..d27ead0c7 100644 --- a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts +++ b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts @@ -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, diff --git a/src/app/system/roles-and-permissions/view-role/view-role.component.ts b/src/app/system/roles-and-permissions/view-role/view-role.component.ts index 5ec5a8c6a..9ebe68463 100644 --- a/src/app/system/roles-and-permissions/view-role/view-role.component.ts +++ b/src/app/system/roles-and-permissions/view-role/view-role.component.ts @@ -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 { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html new file mode 100644 index 000000000..c75bdee8c --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html @@ -0,0 +1,161 @@ + + +
+
+ + + + + + +
+ @for (p of datePresets; track p.key) { + + } +
+ + +
+ + @if (loading) { +
+

Loading…

+
+ } @else if (loaded && requests.length === 0) { +
+ +

No {{ filterStatus | lowercase }} user requests

+

No pending user operations found for this filter.

+
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{{ req.id }}Action + + {{ req.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} + + Username{{ req.username || '—' }}Name{{ req.firstname || '' }} {{ req.lastname || '' }}Email{{ req.email || '—' }}Office ID{{ req.officeId || '—' }}Submitted By{{ req.makerUsername }}Submitted On{{ formatDate(req.submittedDate) }}Status + {{ req.status }} + Actions +
+ + + @if (req.status === 'PENDING') { + + + + } +
+
+ + +
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss new file mode 100644 index 000000000..26817ce7d --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts new file mode 100644 index 000000000..7facd46f0 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts @@ -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 = { + 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(); + }); + } + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html new file mode 100644 index 000000000..9fe40c175 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html @@ -0,0 +1,75 @@ + + +

+ {{ data.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} Request + — {{ data.username }} +

+ + +
+
+ Request ID + #{{ data.requestId }} +
+
+ Submitted By + {{ data.makerUsername }} +
+
+ Submitted On + {{ data.submittedDate }} +
+
+ Status + + {{ data.status }} + +
+
+ + @if (data.rejectReason) { +
Rejection Reason: {{ data.rejectReason }}
+ } + + + + + + @if (data.requestType === 'UPDATE_USER') { + + } + + + + + @for (r of data.rows; track r.key) { + + + @if (data.requestType === 'UPDATE_USER') { + + } + + + } + +
FieldCurrent ValueProposed Value
{{ r.label }}{{ r.current != null && r.current !== '' ? r.current : '—' }} + {{ r.value != null && r.value !== '' ? r.value : '—' }} +
+
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss new file mode 100644 index 000000000..f71168f8a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss @@ -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; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts new file mode 100644 index 000000000..6e8f95f80 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts @@ -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); + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index e47b8257a..433e1d7da 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -108,6 +108,15 @@ Pending Client Edits + + + Pending User Approvals + { - // 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); diff --git a/src/app/users/users.service.ts b/src/app/users/users.service.ts index 32c395a6b..41cf1348b 100644 --- a/src/app/users/users.service.ts +++ b/src/app/users/users.service.ts @@ -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} Pending users + */ + getPendingUsers( + status?: string, + username?: string, + fromDate?: string, + toDate?: string, + offset: number = 0, + limit: number = 20 + ): Observable { + 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} + */ + getPendingUser(id: string | number): Observable { + return this.http.get(`/users/pending/${id}`); + } + + /** + * @param {string | number} id Pending request ID + * @returns {Observable} + */ + approvePendingUser(id: string | number): Observable { + return this.http.post(`/users/pending/${id}/approve`, {}); + } + + /** + * @param {string | number} id Pending request ID + * @param {string} reason Rejection reason + * @returns {Observable} + */ + rejectPendingUser(id: string | number, reason: string): Observable { + return this.http.post(`/users/pending/${id}/reject`, { reason }); + } } diff --git a/src/app/users/view-user/view-user.component.ts b/src/app/users/view-user/view-user.component.ts index 0b224ee6e..00da13d08 100644 --- a/src/app/users/view-user/view-user.component.ts +++ b/src/app/users/view-user/view-user.component.ts @@ -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']); }); } diff --git a/src/assets/styles/_product-list.scss b/src/assets/styles/_product-list.scss index d1cfc0934..5f8cea6d6 100644 --- a/src/assets/styles/_product-list.scss +++ b/src/assets/styles/_product-list.scss @@ -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;