Merge branch 'maker-checker' into dev
Brings in the UI side of maker-checker for users and roles: a Pending User Edits tab with a compare dialog, user/role create-edit-view wired to the pending-request flow, plus keyboard shortcuts and inline-create dialogs. Both conflicts resolved in dev's favour: .env.ts — auto-generated build stamp; dev's (260901) is the newer one. share-product-general-tab.component.html — dev had deliberately removed the per-product Dividends button, leaving a comment explaining that dividends are declared society-wide from Share Products -> Dividends because the board resolves one rate for the year rather than one per product. The branch simply predates that change, so taking its side would have resurrected a button that was removed on purpose. checker-inbox-and-tasks.component.html and tasks-routing.module.ts auto-merged cleanly, with both the new Pending User Edits tab and the existing Closure Requests tab intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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 { Injectable, inject, OnDestroy } from '@angular/core';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { fromEvent, Subscription, BehaviorSubject } from 'rxjs';
|
||||
import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config';
|
||||
import { AuthenticationService } from '../authentication/authentication.service';
|
||||
import { UserPreferenceService } from './user-preference.service';
|
||||
import { DocumentationLinksService } from '../../shared/services/documentation-links.service';
|
||||
|
||||
export interface ShortcutItem {
|
||||
title: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
key: string;
|
||||
route: string;
|
||||
id?: string;
|
||||
custom?: boolean;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KeyboardShortcutService implements OnDestroy {
|
||||
private router = inject(Router);
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private userPreferenceService = inject(UserPreferenceService);
|
||||
private documentationLinks = inject(DocumentationLinksService);
|
||||
|
||||
private keydownSubscription: Subscription;
|
||||
private activeShortcutsSubject = new BehaviorSubject<ShortcutItem[]>([]);
|
||||
public activeShortcuts$ = this.activeShortcutsSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.keydownSubscription = fromEvent<KeyboardEvent>(window, 'keydown').subscribe((event) => {
|
||||
this.handleKeydown(event);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.keydownSubscription) {
|
||||
this.keydownSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public initShortcuts(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
if (!credentials || !credentials.userId) {
|
||||
this.loadDefaults();
|
||||
return;
|
||||
}
|
||||
|
||||
this.userPreferenceService.getPreference(credentials.userId, 'keyboard_shortcuts').subscribe({
|
||||
next: (data) => {
|
||||
if (data && data.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.value);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((shortcut: ShortcutItem) => {
|
||||
if (shortcut.custom && shortcut.action === 'navigate' && shortcut.route) {
|
||||
shortcut.route = this.normalizeRouteStr(shortcut.route);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.activeShortcutsSubject.next(parsed);
|
||||
} catch (e) {
|
||||
console.error('Error parsing shortcuts', e);
|
||||
this.loadDefaults();
|
||||
}
|
||||
} else {
|
||||
this.loadDefaults();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.loadDefaults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public loadDefaults(): void {
|
||||
const config = new KeyboardShortcutsConfiguration();
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
}
|
||||
|
||||
public updateShortcuts(shortcuts: ShortcutItem[]): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
if (credentials && credentials.userId) {
|
||||
const cleanedShortcuts = shortcuts.map((s) => {
|
||||
const clean: any = {
|
||||
title: s.title,
|
||||
ctrlKey: s.ctrlKey,
|
||||
shiftKey: s.shiftKey,
|
||||
altKey: s.altKey,
|
||||
key: s.key,
|
||||
route: s.route
|
||||
};
|
||||
if (s.id !== undefined) clean.id = s.id;
|
||||
if (s.custom !== undefined) clean.custom = s.custom;
|
||||
if (s.action !== undefined) clean.action = s.action;
|
||||
return clean;
|
||||
});
|
||||
|
||||
this.userPreferenceService
|
||||
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(cleanedShortcuts))
|
||||
.subscribe(() => {
|
||||
this.activeShortcutsSubject.next(shortcuts);
|
||||
});
|
||||
} else {
|
||||
this.activeShortcutsSubject.next(shortcuts);
|
||||
}
|
||||
}
|
||||
|
||||
public resetToDefaults(): void {
|
||||
const credentials = this.authenticationService.getCredentials();
|
||||
const config = new KeyboardShortcutsConfiguration();
|
||||
if (credentials && credentials.userId) {
|
||||
this.userPreferenceService
|
||||
.updatePreference(credentials.userId, 'keyboard_shortcuts', JSON.stringify(config.buttonsArray))
|
||||
.subscribe(() => {
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
});
|
||||
} else {
|
||||
this.activeShortcutsSubject.next(config.buttonsArray);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
const activeShortcuts = this.activeShortcutsSubject.getValue();
|
||||
const routeD = activeShortcuts.find(
|
||||
(x) =>
|
||||
x.ctrlKey === event.ctrlKey &&
|
||||
x.shiftKey === event.shiftKey &&
|
||||
x.altKey === event.altKey &&
|
||||
x.key.toLowerCase() === event.key.toLowerCase()
|
||||
);
|
||||
|
||||
if (routeD !== undefined) {
|
||||
event.preventDefault(); // Prevent default browser actions for matched shortcuts
|
||||
|
||||
if (routeD.custom) {
|
||||
if (routeD.action === 'navigate' && routeD.route) {
|
||||
this.router.navigate([routeD.route]);
|
||||
}
|
||||
} else {
|
||||
switch (routeD.id) {
|
||||
case 'logout':
|
||||
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
|
||||
break;
|
||||
case 'help':
|
||||
this.documentationLinks.open('userManual');
|
||||
break;
|
||||
case 'runReport':
|
||||
const runReportElement = document.getElementById('runReport');
|
||||
if (runReportElement) runReportElement.click();
|
||||
break;
|
||||
case 'cancel':
|
||||
const cancelEl = document.querySelector('[data-shortcut-action="cancel"]') as HTMLElement;
|
||||
if (cancelEl) {
|
||||
cancelEl.click();
|
||||
} else {
|
||||
const cancelButtons = Array.from(document.querySelectorAll('button')).filter(
|
||||
(el) => el.textContent && el.textContent.trim() === 'Cancel'
|
||||
);
|
||||
if (cancelButtons.length > 0) cancelButtons[0].click();
|
||||
}
|
||||
break;
|
||||
case 'submit':
|
||||
const submitEl = document.querySelector('[data-shortcut-action="submit"]') as HTMLElement;
|
||||
if (submitEl) {
|
||||
submitEl.click();
|
||||
} else {
|
||||
const submitButtons = Array.from(document.querySelectorAll('button')).filter(
|
||||
(el) => el.textContent && el.textContent.trim() === 'Submit'
|
||||
);
|
||||
if (submitButtons.length > 0) submitButtons[0].click();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.router.navigate([routeD.route], { relativeTo: this.activatedRoute });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public normalizeRouteStr(route: string | undefined | null): string {
|
||||
if (!route) return '';
|
||||
let r = route.trim();
|
||||
|
||||
try {
|
||||
if (r.startsWith('http://') || r.startsWith('https://')) {
|
||||
const url = new URL(r);
|
||||
if (url.hash && url.hash.startsWith('#')) {
|
||||
let path = url.hash.substring(1);
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (r.includes('#/')) {
|
||||
r = r.substring(r.indexOf('#/') + 1);
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z0-9]+:/.test(r)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (!r.startsWith('/')) {
|
||||
r = '/' + r;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject, OnDestroy } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { AuthenticationService } from '../authentication/authentication.service';
|
||||
import { KeyboardShortcutService } from './keyboard-shortcut.service';
|
||||
import { UserPreferenceService } from './user-preference.service';
|
||||
import { KeyboardShortcutsDialogComponent } from '../../shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ShortcutSuggestionService implements OnDestroy {
|
||||
private router = inject(Router);
|
||||
private titleService = inject(Title);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private keyboardShortcutService = inject(KeyboardShortcutService);
|
||||
private userPreferenceService = inject(UserPreferenceService);
|
||||
|
||||
private readonly FREQUENCY_THRESHOLD = 5;
|
||||
private readonly PREF_KEY = 'shortcut_suggestions_state';
|
||||
private routerSubscription!: Subscription;
|
||||
|
||||
private state: { dismissed: string[] } = { dismissed: [] };
|
||||
|
||||
private get userId(): number | null {
|
||||
const creds = this.authenticationService.getCredentials();
|
||||
return creds ? creds.userId : null;
|
||||
}
|
||||
|
||||
private getVisitsKey(): string {
|
||||
return `route_visits_${this.userId}`;
|
||||
}
|
||||
|
||||
private getVisits(): Record<string, number> {
|
||||
const key = this.getVisitsKey();
|
||||
if (!key) return {};
|
||||
const val = localStorage.getItem(key);
|
||||
return val ? JSON.parse(val) : {};
|
||||
}
|
||||
|
||||
private saveVisits(visits: Record<string, number>) {
|
||||
const key = this.getVisitsKey();
|
||||
if (key) {
|
||||
localStorage.setItem(key, JSON.stringify(visits));
|
||||
}
|
||||
}
|
||||
|
||||
private isSuggestionOpen = false;
|
||||
|
||||
public init() {
|
||||
const uid = this.userId;
|
||||
if (uid) {
|
||||
this.userPreferenceService.getPreference(uid, this.PREF_KEY).subscribe({
|
||||
next: (data) => {
|
||||
if (data && data.value) {
|
||||
try {
|
||||
this.state = JSON.parse(data.value);
|
||||
if (!this.state.dismissed) this.state.dismissed = [];
|
||||
} catch (e) {
|
||||
this.state = { dismissed: [] };
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
this.routerSubscription = this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe((event: any) => {
|
||||
this.handleNavigation(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
private handleNavigation(url: string) {
|
||||
if (!this.userId) return;
|
||||
|
||||
let path = url.split('?')[0];
|
||||
path = this.keyboardShortcutService.normalizeRouteStr(path);
|
||||
|
||||
const visits = this.getVisits();
|
||||
visits[path] = (visits[path] || 0) + 1;
|
||||
this.saveVisits(visits);
|
||||
}
|
||||
|
||||
public getUsageData(): { route: string; pageName: string; visits: number; hasShortcut: boolean }[] {
|
||||
const visits = this.getVisits();
|
||||
const result: { route: string; pageName: string; visits: number; hasShortcut: boolean }[] = [];
|
||||
|
||||
// Get current shortcuts (sync)
|
||||
let currentShortcuts: any[] = [];
|
||||
this.keyboardShortcutService.activeShortcuts$.pipe(take(1)).subscribe((s) => (currentShortcuts = s));
|
||||
|
||||
for (const route of Object.keys(visits)) {
|
||||
const count = visits[route];
|
||||
const hasShortcut = currentShortcuts.some(
|
||||
(s) =>
|
||||
(s.custom && s.action === 'navigate' && this.keyboardShortcutService.normalizeRouteStr(s.route) === route) ||
|
||||
(!s.custom && this.keyboardShortcutService.normalizeRouteStr(s.route) === route)
|
||||
);
|
||||
|
||||
let pageName = route.split('/').pop() || route;
|
||||
// Capitalize and clean
|
||||
pageName = pageName.replace(/-/g, ' ');
|
||||
pageName = pageName.charAt(0).toUpperCase() + pageName.slice(1);
|
||||
|
||||
result.push({ route, pageName, visits: count, hasShortcut });
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.visits - a.visits);
|
||||
}
|
||||
|
||||
public markAsDismissed(route: string) {
|
||||
if (!this.state.dismissed.includes(route)) {
|
||||
this.state.dismissed.push(route);
|
||||
const uid = this.userId;
|
||||
if (uid) {
|
||||
this.userPreferenceService.updatePreference(uid, this.PREF_KEY, JSON.stringify(this.state)).subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public openCreateShortcutDialog(pageName: string, route: string) {
|
||||
this.dialog
|
||||
.open(KeyboardShortcutsDialogComponent, {
|
||||
data: {
|
||||
prefill: {
|
||||
title: pageName,
|
||||
route: route,
|
||||
action: 'navigate'
|
||||
}
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((saved) => {
|
||||
if (saved) {
|
||||
this.markAsDismissed(route);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.routerSubscription) {
|
||||
this.routerSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UserPreferenceService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getPreference(userId: number, preferenceName: string): Observable<{ name: string; value: string }> {
|
||||
const timestamp = new Date().getTime();
|
||||
return this.http.get<{ name: string; value: string }>(
|
||||
`/users/${userId}/preferences/${preferenceName}?_t=${timestamp}`
|
||||
);
|
||||
}
|
||||
|
||||
updatePreference(userId: number, preferenceName: string, value: string): Observable<{ name: string; value: string }> {
|
||||
return this.http.put<{ name: string; value: string }>(`/users/${userId}/preferences/${preferenceName}`, { value });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user