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,