From fa2522e67833649662ad47973323751f28613107 Mon Sep 17 00:00:00 2001 From: anshul Date: Tue, 25 Aug 2026 18:51:03 +0530 Subject: [PATCH 1/2] Implement keyboard shortcuts and workflow changes --- .../create-gl-account.component.html | 2 +- .../create-gl-account.component.ts | 48 ++- src/app/clients/clients.service.ts | 2 +- .../services/external-national-id.service.ts | 2 +- .../services/keyboard-shortcut.service.ts | 228 +++++++++++++ .../services/shortcut-suggestion.service.ts | 161 +++++++++ .../core/services/user-preference.service.ts | 28 ++ src/app/keyboards-shortcut-config.ts | 39 +-- .../create-charge/create-charge.component.ts | 14 +- .../edit-charge/edit-charge.component.ts | 14 +- .../create-current-product.component.ts | 28 +- ...base-fd-interest-rate-chart.component.html | 17 +- ...base-fd-interest-rate-chart.component.scss | 24 ++ .../base-fd-interest-rate-chart.component.ts | 152 ++++++++- .../create-loan-product.component.ts | 28 +- .../create-saving-product.component.ts | 30 +- .../create-share-product.component.ts | 28 +- src/app/profile/profile.component.html | 4 + src/app/profile/profile.component.ts | 9 +- src/app/rbi-circulars/openproject.service.ts | 2 +- .../keyboard-shortcuts-dialog.component.html | 226 +++++++++++-- .../keyboard-shortcuts-dialog.component.scss | 158 ++++++++- .../keyboard-shortcuts-dialog.component.ts | 314 +++++++++++++++++- ...hortcut-suggestion-snackbar.component.html | 22 ++ ...hortcut-suggestion-snackbar.component.scss | 19 ++ .../shortcut-suggestion-snackbar.component.ts | 34 ++ ...line-create-shortcut-dialog.component.html | 55 +++ ...inline-create-shortcut-dialog.component.ts | 129 +++++++ .../shortcut-usage-dialog.component.html | 58 ++++ .../shortcut-usage-dialog.component.scss | 9 + .../shortcut-usage-dialog.component.ts | 92 +++++ .../checker-inbox/checker-inbox.component.ts | 4 +- .../view-checker-inbox.component.ts | 5 +- src/app/web-app.component.ts | 54 +-- src/environments/.env.ts | 4 +- tsconfig.json | 3 + 36 files changed, 1865 insertions(+), 181 deletions(-) create mode 100644 src/app/core/services/keyboard-shortcut.service.ts create mode 100644 src/app/core/services/shortcut-suggestion.service.ts create mode 100644 src/app/core/services/user-preference.service.ts create mode 100644 src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html create mode 100644 src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss create mode 100644 src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts create mode 100644 src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html create mode 100644 src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts create mode 100644 src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html create mode 100644 src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss create mode 100644 src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts diff --git a/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html b/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html index 438df32b8..abff82456 100644 --- a/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html +++ b/src/app/accounting/chart-of-accounts/create-gl-account/create-gl-account.component.html @@ -119,7 +119,7 @@ + - } - + + } @else { +
+ +
+ } + + + +
+ + +
+
+ + +

{{ data.title }}

+
+
+
+ Shortcut Name:
+ {{ data.title }} +
+
+ Key Combination:
+ {{ formatShortcut(data) }} +
+
+ Action:
+ {{ data.action === 'navigate' ? 'Navigate' : data.action || data.id || 'Predefined' }} +
+ @if (data.route) { +
+ URL / Route:
+ {{ data.route }} +
+ } +
+ Description:
+ {{ getShortcutDescription(data) }} +
+
+
+ + + + + +
diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss index 9ae3dc36b..4160a3a6e 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss @@ -6,15 +6,157 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.modifier-key { - border: 1px solid #0000004d; - padding: 0.3rem; - background: #f8f8f8; - color: #00000086; - font-weight: bold; - border-radius: 0.3rem; -} +$primary: #1a73e8; +$gray-50: #f8f9fa; +$gray-100: #f1f3f4; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-700: #5f6368; +$gray-900: #202124; +$red: #ef4444; +$navy: #0d1b2a; +$white: #fff; .mat-divider.mat-divider-inset { margin-left: 0; } + +/* --- Header (from form-dialog) --- */ +.ks-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px 12px; + border-bottom: 1px solid $gray-200; +} + +.ks-title { + margin: 0 !important; + padding: 0 !important; + font-size: 17px !important; + font-weight: 600 !important; + color: $gray-900; + letter-spacing: -0.2px; +} + +/* --- Table Rows (from bank-calendar) --- */ +.ks-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f1f5f9; + color: #475569; + text-align: left; + padding: 10px 14px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid #e2e8f0; + } + + tbody td { + padding: 9px 14px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: $navy; + } + + tbody tr { + transition: background 0.12s; + + &:hover { + background: #f8fafc; + } + } +} + +/* --- Inputs/Forms (from form-dialog) --- */ +.ks-field { + min-width: 0; + + ::ng-deep { + .mat-mdc-form-field { + width: 100%; + } + + .mat-mdc-text-field-wrapper { + background: $gray-50; + border-radius: 8px; + } + + .mdc-text-field--outlined .mdc-notched-outline__leading, + .mdc-text-field--outlined .mdc-notched-outline__trailing, + .mdc-text-field--outlined .mdc-notched-outline__notch { + border-color: $gray-200; + } + + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__leading, + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__trailing, + .mdc-text-field--outlined:not(.mdc-text-field--disabled):hover .mdc-notched-outline__notch { + border-color: $primary; + } + + .mat-mdc-floating-label { + font-size: 13px; + color: $gray-700; + } + + input.mat-mdc-input-element { + font-size: 14px; + color: $gray-900; + } + } +} + +/* --- Dialog Footer & Buttons (from confirmation-dialog) --- */ +.ks-actions { + padding: 14px 24px 20px !important; + border-top: 1px solid $gray-200; + margin: 0 !important; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + gap: 10px !important; + + .ks-actions__right { + display: flex; + gap: 10px; + align-items: center; + } + + &__cancel { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 20px !important; + height: 38px !important; + color: $gray-700 !important; + border-color: $gray-200 !important; + } + + &__confirm { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + padding: 0 24px !important; + height: 38px !important; + letter-spacing: 0.2px; + color: $white !important; + + &--basic { + background: $primary !important; + box-shadow: 0 1px 3px rgb(26 115 232 / 30%) !important; + } + + &--warn { + background: $red !important; + box-shadow: 0 1px 3px rgb(239 68 68 / 30%) !important; + } + } +} diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts index 5ed5ef70e..d4c0ca1b5 100644 --- a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts @@ -6,16 +6,31 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; -import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogContent } from '@angular/material/dialog'; -import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config'; +import { Component, OnInit, inject, ViewChild } from '@angular/core'; +import { + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialog, + MatDialogClose, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatDivider } from '@angular/material/divider'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; -/** - * Delete dialog component. - */ +import { KeyboardShortcutService, ShortcutItem } from '../../core/services/keyboard-shortcut.service'; +import { FormsModule } from '@angular/forms'; +import { NgClass } from '@angular/common'; +import { MatButton } from '@angular/material/button'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { ShortcutUsageDialogComponent } from '../shortcut-usage-dialog/shortcut-usage-dialog.component'; +import { MatIcon } from '@angular/material/icon'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatSelect } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; + @Component({ selector: 'mifosx-keyboard-shortcuts-dialog', templateUrl: './keyboard-shortcuts-dialog.component.html', @@ -25,16 +40,293 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatDialogTitle, CdkScrollable, MatDialogContent, - MatDivider + MatDialogActions, + MatDialogClose, + MatDivider, + MatButton, + FormsModule, + NgClass, + MatIcon, + MatFormField, + MatLabel, + MatInput, + MatSelect, + MatOption ] }) export class KeyboardShortcutsDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); + private keyboardShortcutService = inject(KeyboardShortcutService); + public data = inject(MAT_DIALOG_DATA, { optional: true }); - buttonConfig: KeyboardShortcutsConfiguration; + shortcuts: ShortcutItem[] = []; + validationError: string | null = null; + activeInputItem: ShortcutItem | null = null; + displayedColumns: string[] = [ + 'title', + 'keyCombination' + ]; + + isAddingNew = false; + newShortcut: Partial = {}; + activeNewInput = false; + + editingShortcutId: string | null = null; + editingShortcutData: Partial = {}; + activeEditInput = false; ngOnInit() { - this.dialogRef.updateSize(`800px`); - this.buttonConfig = new KeyboardShortcutsConfiguration(); + this.dialogRef.updateSize('800px'); + this.keyboardShortcutService.activeShortcuts$.subscribe((shortcuts) => { + // Deep copy to allow editing without affecting the global state immediately + this.shortcuts = JSON.parse(JSON.stringify(shortcuts)); + }); + + if (this.data && this.data.prefill) { + this.startAddShortcut(); + this.newShortcut = { + ...this.newShortcut, + ...this.data.prefill + }; + } + } + + formatShortcut(item: Partial): string { + if (!item || (!item.key && !item.ctrlKey && !item.shiftKey && !item.altKey)) return ''; + const parts = []; + if (item.ctrlKey) parts.push('Ctrl'); + if (item.shiftKey) parts.push('Shift'); + if (item.altKey) parts.push('Alt'); + if (item.key) parts.push(item.key.toUpperCase()); + return parts.join(' + '); + } + + onKeyDown(event: KeyboardEvent, item: Partial, isNewOrEdit: boolean = false) { + event.preventDefault(); + event.stopPropagation(); + + // Ignore if only modifier keys are pressed + if ([ + 'Control', + 'Shift', + 'Alt', + 'Meta' + ].includes(event.key)) { + return; + } + + item.ctrlKey = event.ctrlKey; + item.shiftKey = event.shiftKey; + item.altKey = event.altKey; + item.key = event.key.toLowerCase(); + + if (!isNewOrEdit) { + this.activeInputItem = null; + this.validateShortcuts(); + } else { + this.activeNewInput = false; + this.activeEditInput = false; + } + } + + validateShortcuts() { + this.validationError = null; + const seen = new Set(); + + for (const item of this.shortcuts) { + if (!item.key) { + this.validationError = `Invalid shortcut for ${item.title}: Key is required`; + return; + } + + const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`; + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`; + return; + } + seen.add(keyStr); + } + } + + startAddShortcut() { + this.isAddingNew = true; + this.newShortcut = { + title: '', + route: '', + key: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + action: 'navigate', + custom: true, + id: 'custom_' + Date.now() + }; + this.validationError = null; + } + + cancelAddShortcut() { + this.isAddingNew = false; + this.validationError = null; + } + + private dialog = inject(MatDialog); + + @ViewChild('shortcutDetailsTemplate') shortcutDetailsTemplate: any; + private detailsDialogRef: MatDialogRef | null = null; + + showShortcutDetails(item: ShortcutItem) { + this.detailsDialogRef = this.dialog.open(this.shortcutDetailsTemplate, { + data: item, + width: '400px' + }); + } + + openUsagePopup() { + this.dialog.open(ShortcutUsageDialogComponent, { + width: '600px' + }); + } + + getShortcutDescription(item: Partial): string { + if (item.custom) { + if (item.action === 'navigate') { + return `Navigates to the ${item.title} page.`; + } + return `Executes the ${item.action} action.`; + } else { + switch (item.id) { + case 'logout': + return 'Logs you out of the application securely.'; + case 'help': + return 'Opens the user manual and help documentation.'; + case 'runReport': + return 'Executes the currently selected report.'; + case 'cancel': + return 'Cancels the current operation or form.'; + case 'submit': + return 'Submits the current form or dialog.'; + default: + return `Navigates to the predefined ${item.title} feature.`; + } + } + } + + saveNewShortcut() { + if (this.newShortcut.action === 'navigate' && this.newShortcut.route) { + this.newShortcut.route = this.keyboardShortcutService.normalizeRouteStr(this.newShortcut.route); + } + if (this.validateSingleShortcut(this.newShortcut, null)) { + this.shortcuts.push(this.newShortcut as ShortcutItem); + this.isAddingNew = false; + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + } + + startEditShortcut(item: ShortcutItem) { + if (this.detailsDialogRef) { + this.detailsDialogRef.close(); + this.detailsDialogRef = null; + } + this.editingShortcutId = item.id || null; + this.editingShortcutData = { ...item }; + this.validationError = null; + } + + cancelEditShortcut() { + this.editingShortcutId = null; + this.validationError = null; + } + + saveEditShortcut() { + if (this.editingShortcutData.action === 'navigate' && this.editingShortcutData.route) { + this.editingShortcutData.route = this.keyboardShortcutService.normalizeRouteStr(this.editingShortcutData.route); + } + if (this.validateSingleShortcut(this.editingShortcutData, this.editingShortcutId)) { + const index = this.shortcuts.findIndex((s) => s.id === this.editingShortcutId); + if (index > -1) { + this.shortcuts[index] = { ...this.editingShortcutData } as ShortcutItem; + } + this.editingShortcutId = null; + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + } + + deleteShortcut(item: ShortcutItem) { + if (this.detailsDialogRef) { + this.detailsDialogRef.close(); + this.detailsDialogRef = null; + } + this.shortcuts = this.shortcuts.filter((s) => (s.id ? s.id !== item.id : s.title !== item.title)); + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + } + } + + validateSingleShortcut(item: Partial, excludeId: string | null): boolean { + if (!item.title || item.title.trim() === '') { + this.validationError = 'Shortcut Name is required'; + return false; + } + if (!item.key) { + this.validationError = 'Key combination is required'; + return false; + } + if (item.action === 'navigate' && (!item.route || item.route.trim() === '')) { + this.validationError = 'URL / Route is required'; + return false; + } + + const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`; + const seen = new Set(); + + for (const s of this.shortcuts) { + if (excludeId && s.id === excludeId) continue; + seen.add(`${!!s.ctrlKey}-${!!s.shiftKey}-${!!s.altKey}-${s.key.toLowerCase()}`); + } + + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`; + return false; + } + + this.validationError = null; + return true; + } + + save() { + this.validateShortcuts(); + if (!this.validationError) { + this.keyboardShortcutService.updateShortcuts(this.shortcuts); + this.dialogRef.close(true); + } + } + + resetToDefaults() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + data: { + heading: 'Reset Keyboard Shortcuts', + dialogContext: + 'Are you sure you want to reset all keyboard shortcuts to their default configuration? This will remove all custom shortcuts and restore default keys.', + type: 'Warn' + } + }); + + dialogRef.afterClosed().subscribe((confirm: boolean) => { + if (confirm) { + this.keyboardShortcutService.resetToDefaults(); + this.dialogRef.close(true); + } + }); + } + + cancel() { + this.dialogRef.close(false); } } diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html new file mode 100644 index 000000000..a4f23b40c --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.html @@ -0,0 +1,22 @@ + + +
+
+ 💡 You frequently use {{ data.pageName }}
+ Would you like to create a keyboard shortcut for faster access? +
+
+ + +
+
diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss new file mode 100644 index 000000000..56165bf4f --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.scss @@ -0,0 +1,19 @@ +/** + * 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/. + */ + +.suggestion-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.suggestion-text { + font-size: 14px; + line-height: 1.4; + color: #fff; +} diff --git a/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts new file mode 100644 index 000000000..86eeddafd --- /dev/null +++ b/src/app/shared/shortcut-suggestion-snackbar/shortcut-suggestion-snackbar.component.ts @@ -0,0 +1,34 @@ +/** + * 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 { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material/snack-bar'; +import { MatButton } from '@angular/material/button'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +@Component({ + selector: 'mifosx-shortcut-suggestion-snackbar', + templateUrl: './shortcut-suggestion-snackbar.component.html', + styleUrls: ['./shortcut-suggestion-snackbar.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatButton + ] +}) +export class ShortcutSuggestionSnackbarComponent { + snackBarRef = inject(MatSnackBarRef); + data: { pageName: string; route: string } = inject(MAT_SNACK_BAR_DATA); + + createShortcut() { + this.snackBarRef.dismissWithAction(); + } + + dismiss() { + this.snackBarRef.dismiss(); + } +} diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html new file mode 100644 index 000000000..492d555ac --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.html @@ -0,0 +1,55 @@ + + +
+

Create Shortcut

+
+ +
+ @if (validationError) { +
+ {{ validationError }} +
+ } + +
+ + Shortcut Name + + + + + Key Combination + + + + + Route + + + + + Action + + +
+
+ + +
+
+ + +
+
diff --git a/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts new file mode 100644 index 000000000..741a0c065 --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/inline-create-shortcut-dialog.component.ts @@ -0,0 +1,129 @@ +/** + * 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 { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import { KeyboardShortcutService, ShortcutItem } from '../../core/services/keyboard-shortcut.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FormsModule } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { take } from 'rxjs/operators'; + +@Component({ + selector: 'mifosx-inline-create-shortcut-dialog', + templateUrl: './inline-create-shortcut-dialog.component.html', + styleUrls: ['../keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatButton, + FormsModule, + MatFormField, + MatLabel, + MatInput, + MatError + ] +}) +export class InlineCreateShortcutDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + private keyboardShortcutService = inject(KeyboardShortcutService); + + newShortcut: Partial = { + title: this.data.pageName, + route: this.data.route, + action: 'navigate', + key: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + custom: true, + id: 'custom_' + Date.now() + }; + + validationError: string | null = null; + + formatShortcut(item: Partial): string { + if (!item || (!item.key && !item.ctrlKey && !item.shiftKey && !item.altKey)) return ''; + const parts = []; + if (item.ctrlKey) parts.push('Ctrl'); + if (item.shiftKey) parts.push('Shift'); + if (item.altKey) parts.push('Alt'); + if (item.key) parts.push(item.key.toUpperCase()); + return parts.join(' + '); + } + + onKeyDown(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + // Ignore if only modifier keys are pressed + if ([ + 'Control', + 'Shift', + 'Alt', + 'Meta' + ].includes(event.key)) { + return; + } + + this.newShortcut.ctrlKey = event.ctrlKey; + this.newShortcut.shiftKey = event.shiftKey; + this.newShortcut.altKey = event.altKey; + this.newShortcut.key = event.key.toLowerCase(); + this.validationError = null; + } + + save() { + if (!this.newShortcut.title || this.newShortcut.title.trim() === '') { + this.validationError = 'Shortcut Name is required'; + return; + } + if (!this.newShortcut.key) { + this.validationError = 'Key combination is required'; + return; + } + + const keyStr = `${!!this.newShortcut.ctrlKey}-${!!this.newShortcut.shiftKey}-${!!this.newShortcut.altKey}-${this.newShortcut.key.toLowerCase()}`; + + this.keyboardShortcutService.activeShortcuts$.pipe(take(1)).subscribe((shortcuts) => { + // Check for duplicates + const seen = new Set(); + for (const s of shortcuts) { + seen.add(`${!!s.ctrlKey}-${!!s.shiftKey}-${!!s.altKey}-${s.key.toLowerCase()}`); + } + + if (seen.has(keyStr)) { + this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(this.newShortcut)}`; + return; + } + + // Add and save + if (this.newShortcut.route) { + this.newShortcut.route = this.keyboardShortcutService.normalizeRouteStr(this.newShortcut.route); + } + shortcuts.push(this.newShortcut as ShortcutItem); + this.keyboardShortcutService.updateShortcuts(shortcuts); + this.dialogRef.close(true); + }); + } + + cancel() { + this.dialogRef.close(false); + } +} diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html new file mode 100644 index 000000000..68ab24c2f --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.html @@ -0,0 +1,58 @@ + + +

Shortcut Usage

+ +
+ + + + + + + + + + + + + + + + + + + + + +
Page +
{{ item.pageName }}
+
{{ item.route }}
+
Visits + {{ item.visits }} + Status / Action + @if (item.hasShortcut) { + ✓ Shortcut exists + } @else if (item.visits >= 5) { + Frequently Used + + } @else { + Not enough visits + } +
+ + @if (usageData.length === 0) { +
+ No usage data collected yet. +
+ } +
+ + + + diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss new file mode 100644 index 000000000..67af7e8fd --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.scss @@ -0,0 +1,9 @@ +/** + * 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/. + */ + +// Usage dialog styling diff --git a/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts new file mode 100644 index 000000000..8f8db38ba --- /dev/null +++ b/src/app/shared/shortcut-usage-dialog/shortcut-usage-dialog.component.ts @@ -0,0 +1,92 @@ +/** + * 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 { + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatDialog +} from '@angular/material/dialog'; +import { ShortcutSuggestionService } from '../../core/services/shortcut-suggestion.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { MatButton } from '@angular/material/button'; +import { + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { InlineCreateShortcutDialogComponent } from './inline-create-shortcut-dialog.component'; + +@Component({ + selector: 'mifosx-shortcut-usage-dialog', + templateUrl: './shortcut-usage-dialog.component.html', + styleUrls: ['./shortcut-usage-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow + ] +}) +export class ShortcutUsageDialogComponent implements OnInit { + dialogRef = inject>(MatDialogRef); + private shortcutSuggestionService = inject(ShortcutSuggestionService); + private dialog = inject(MatDialog); + + usageData: { route: string; pageName: string; visits: number; hasShortcut: boolean }[] = []; + displayedColumns: string[] = [ + 'page', + 'visits', + 'status' + ]; + + ngOnInit() { + this.refreshData(); + } + + refreshData() { + this.usageData = this.shortcutSuggestionService.getUsageData(); + } + + createShortcut(item: { pageName: string; route: string }) { + const inlineDialogRef = this.dialog.open(InlineCreateShortcutDialogComponent, { + data: item, + width: '400px', + disableClose: true + }); + + inlineDialogRef.afterClosed().subscribe((saved) => { + if (saved) { + this.shortcutSuggestionService.markAsDismissed(item.route); + this.refreshData(); // update the table so the checkmark appears! + } + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts index 8f7ec6d2c..6800f4e61 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component.ts @@ -123,7 +123,9 @@ export class CheckerInboxComponent implements OnInit { ]; private excludeProductEdits(items: any[]): any[] { - return (items || []).filter((item: any) => !this.productEditEntities.includes(item?.entityName)); + return (items || []).filter( + (item: any) => !(this.productEditEntities.includes(item?.entityName) && item?.actionName === 'UPDATE') + ); } /** diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index 44ebee780..5bef1b3e1 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -65,7 +65,10 @@ export class ViewCheckerInboxComponent { ]; get isProductEditEntry(): boolean { - return this.productEditEntities.includes(this.checkerInboxDetail?.entityName); + return ( + this.productEditEntities.includes(this.checkerInboxDetail?.entityName) && + this.checkerInboxDetail?.actionName === 'UPDATE' + ); } /** diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index 43ad9a332..c27a3984f 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -37,11 +37,12 @@ import { ErrorDialogComponent } from './shared/error-dialog/error-dialog.compone /** Custom Items */ import { Alert } from './core/alert/alert.model'; -import { KeyboardShortcutsConfiguration } from './keyboards-shortcut-config'; import { Dates } from './core/utils/dates'; import { animate, style, transition, trigger } from '@angular/animations'; import { I18nService } from './core/i18n/i18n.service'; import { ThemingService } from './shared/theme-toggle/theming.service'; +import { KeyboardShortcutService } from './core/services/keyboard-shortcut.service'; +import { ShortcutSuggestionService } from './core/services/shortcut-suggestion.service'; /** Initialize Logger */ const log = new Logger('MifosX'); @@ -97,8 +98,6 @@ registerLocaleData(localeSW); standalone: false }) export class WebAppComponent implements OnInit, OnDestroy { - buttonConfig: KeyboardShortcutsConfiguration; - i18nService: I18nService; private authSubscription: Subscription; @@ -132,7 +131,9 @@ export class WebAppComponent implements OnInit, OnDestroy { private dateUtils: Dates, private idle: IdleTimeoutService, private dialog: MatDialog, - private documentationLinks: DocumentationLinksService + private documentationLinks: DocumentationLinksService, + private keyboardShortcutService: KeyboardShortcutService, + private shortcutSuggestionService: ShortcutSuggestionService ) {} @HostBinding('class') public cssClass: string; @@ -246,7 +247,6 @@ export class WebAppComponent implements OnInit, OnDestroy { verticalPosition: 'top' }); }); - this.buttonConfig = new KeyboardShortcutsConfiguration(); // initialize language and date format if they are null. if (!localStorage.getItem('mifosXLanguage')) { @@ -269,6 +269,8 @@ export class WebAppComponent implements OnInit, OnDestroy { this.authSubscription = this.authenticationService.isAuthenticated$.subscribe((loggedIn) => { if (loggedIn) { this.idle.start(); + this.keyboardShortcutService.initShortcuts(); + this.shortcutSuggestionService.init(); } else { this.idle.stop(); } @@ -311,46 +313,4 @@ export class WebAppComponent implements OnInit, OnDestroy { help() { this.documentationLinks.open('userManual'); } - - // Monitor all keyboard events and excute keyboard shortcuts - @HostListener('window:keydown', ['$event']) - onKeydownHandler(event: KeyboardEvent) { - const routeD = this.buttonConfig.buttonCombinations.find( - (x) => - x.ctrlKey === event.ctrlKey && x.shiftKey === event.shiftKey && x.altKey === event.altKey && x.key === event.key - ); - if (!(routeD === undefined)) { - switch (routeD.id) { - case 'logout': - this.logout(); - break; - case 'help': - this.help(); - break; - case 'runReport': - document.getElementById('runReport').click(); - break; - case 'cancel': - const cancelButtons = document.querySelectorAll('button'); - const filteredcancelButtons = Array.prototype.filter.call(cancelButtons, function (el: any) { - return el.textContent.trim() === 'Cancel'; - }); - if (filteredcancelButtons.length > 0) { - filteredcancelButtons[0].click(); - } - break; - case 'submit': - const submitButton = document.querySelectorAll('button'); - const filteredSubmitButton = Array.prototype.filter.call(submitButton, function (el: any) { - return el.textContent.trim() === 'Submit'; - }); - if (filteredSubmitButton.length > 0) { - filteredSubmitButton[0].click(); - } - break; - default: - this.router.navigate([routeD.route], { relativeTo: this.activatedRoute }); - } - } - } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index fd0d2a130..4c756f68e 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260813', - 'hash': '5a6fb1f9' + 'version': '260825', + 'hash': '1b2ef512' }, 'allow_switching_backend_instance': true }; diff --git a/tsconfig.json b/tsconfig.json index f505ac664..31444082c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ ], "app/*": [ "src/app/*" + ], + "environments/*": [ + "src/environments/*" ] }, "importHelpers": true, From abc966d507ebf46fed28f6d609aa4a5dc9499836 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 27 Aug 2026 18:30:45 +0530 Subject: [PATCH 2/2] implement user, role, product, and task management components along with associated services and UI updates --- .../create-fixed-deposit-product.component.ts | 28 +- .../edit-fixed-deposit-product.component.ts | 12 +- .../share-product-general-tab.component.html | 336 ++++++++++-------- .../share-product-general-tab.component.scss | 114 +++++- .../add-role/add-role.component.ts | 9 +- .../edit-role/edit-role.component.ts | 11 +- .../view-role/view-role.component.ts | 36 +- .../pending-user-edits.component.html | 161 +++++++++ .../pending-user-edits.component.scss | 221 ++++++++++++ .../pending-user-edits.component.ts | 323 +++++++++++++++++ .../user-edit-compare-dialog.component.html | 75 ++++ .../user-edit-compare-dialog.component.scss | 141 ++++++++ .../user-edit-compare-dialog.component.ts | 57 +++ .../checker-inbox-and-tasks.component.html | 9 + src/app/tasks/tasks-routing.module.ts | 6 + .../create-user/create-user.component.ts | 8 + .../users/edit-user/edit-user.component.ts | 52 +-- src/app/users/users.service.ts | 58 +++ .../users/view-user/view-user.component.ts | 10 +- src/assets/styles/_product-list.scss | 6 + 20 files changed, 1471 insertions(+), 202 deletions(-) create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-user-edits/user-edit-compare-dialog/user-edit-compare-dialog.component.ts 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;