Implement keyboard shortcuts and workflow changes

This commit is contained in:
2026-08-25 18:51:03 +05:30
parent 1b2ef5126e
commit fa2522e678
36 changed files with 1865 additions and 181 deletions
@@ -0,0 +1,55 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ks-header">
<h1 mat-dialog-title class="ks-title">Create Shortcut</h1>
</div>
<div mat-dialog-content>
@if (validationError) {
<div class="alert error margin-b-10" style="color: #ef4444; margin-bottom: 15px; font-size: 13px">
{{ validationError }}
</div>
}
<div class="layout-column gap-10px" style="padding-top: 10px">
<mat-form-field class="ks-field" style="width: 100%">
<mat-label>Shortcut Name</mat-label>
<input matInput [(ngModel)]="newShortcut.title" />
</mat-form-field>
<mat-form-field class="ks-field" style="width: 100%">
<mat-label>Key Combination</mat-label>
<input
matInput
readonly
[value]="formatShortcut(newShortcut)"
(keydown)="onKeyDown($event)"
placeholder="Click and press keys..."
/>
</mat-form-field>
<mat-form-field class="ks-field" style="width: 100%">
<mat-label>Route</mat-label>
<input matInput [ngModel]="newShortcut.route" disabled="true" />
</mat-form-field>
<mat-form-field class="ks-field" style="width: 100%">
<mat-label>Action</mat-label>
<input matInput [ngModel]="'Navigate'" disabled="true" />
</mat-form-field>
</div>
</div>
<mat-dialog-actions class="ks-actions">
<div style="flex: 1"></div>
<div class="ks-actions__right">
<button mat-stroked-button class="ks-actions__cancel" (click)="cancel()">Cancel</button>
<button mat-flat-button class="ks-actions__confirm ks-actions__confirm--basic" (click)="save()">Save</button>
</div>
</mat-dialog-actions>
@@ -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<InlineCreateShortcutDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private keyboardShortcutService = inject(KeyboardShortcutService);
newShortcut: Partial<ShortcutItem> = {
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<ShortcutItem>): 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<string>();
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);
}
}
@@ -0,0 +1,58 @@
<!--
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/.
-->
<h1 mat-dialog-title>Shortcut Usage</h1>
<div mat-dialog-content>
<table mat-table [dataSource]="usageData" class="mat-elevation-z1" style="width: 100%; margin-top: 10px">
<!-- Page Column -->
<ng-container matColumnDef="page">
<th mat-header-cell *matHeaderCellDef>Page</th>
<td mat-cell *matCellDef="let item">
<div>{{ item.pageName }}</div>
<div style="font-size: 11px; color: #888">{{ item.route }}</div>
</td>
</ng-container>
<!-- Visits Column -->
<ng-container matColumnDef="visits">
<th mat-header-cell *matHeaderCellDef style="text-align: center">Visits</th>
<td mat-cell *matCellDef="let item" style="text-align: center">
<span style="font-weight: 500">{{ item.visits }}</span>
</td>
</ng-container>
<!-- Status / Action Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef style="text-align: right">Status / Action</th>
<td mat-cell *matCellDef="let item" style="text-align: right">
@if (item.hasShortcut) {
<span style="color: #4caf50; font-size: 13px; font-weight: 500">✓ Shortcut exists</span>
} @else if (item.visits >= 5) {
<span style="color: #ff9800; font-size: 12px; margin-right: 10px; font-weight: 500">Frequently Used</span>
<button mat-button color="primary" (click)="createShortcut(item)">Create Shortcut</button>
} @else {
<span style="color: #aaa; font-size: 12px">Not enough visits</span>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (usageData.length === 0) {
<div style="padding: 20px; text-align: center; color: #888; border: 1px solid #eee; border-top: none">
No usage data collected yet.
</div>
}
</div>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -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
@@ -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<ShortcutUsageDialogComponent>>(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!
}
});
}
}