Compare commits

...
5 Commits
Author SHA1 Message Date
niteshandClaude Opus 5 63134f172a fix(tasks): lay the checker inbox popup out properly
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
The dialog rendered but read badly: the heading appeared twice, and every field
ran together onto one line ("ID: 125Status: User: mifosAction: DELETE").

Cause was the global flex-40/flex-60/flex-fill utilities the template used --
they are sized for a full-width page, so inside a 680px dialog the label and
value columns collapsed and wrapped into each other. Replaced them with a
self-contained label-above-value grid that reflows to one column when narrow,
so the same markup reads correctly as a page and as a popup.

Also: moved the heading into the shared header so it is rendered once in both
modes, gave the actions real buttons instead of Material raised buttons styled
by two global overrides, put DELETE in a red tag so a destructive entry is
visible at a glance, and dropped MatDivider/MatDialogModule from imports now
that the markup no longer uses them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 18:40:59 +05:30
niteshandClaude Opus 5 e8097bc2cb feat(tasks): open a checker inbox entry in a popup instead of a page
Clicking a row navigated away to its own page, costing the checker the list and
whatever search they had set up. It now opens in a dialog over the list, the
same pattern the Disbursement and Closure Requests tabs already use.

ViewCheckerInboxComponent gained the optional MatDialogRef/MAT_DIALOG_DATA pair
rather than being duplicated, so the direct route still works for a bookmarked
link. Two differences follow from being a dialog:

 - the list rows carry only summary fields, so the detail (which is what has
   commandAsJson) is fetched here; by route it stays resolver-provided. The
   template reads checkerInboxDetail.* unguarded, so it is now behind an @if
   with a loading state -- otherwise a dialog would render against undefined.
 - commandAsJson is absent on some entries (a DELETE carries no payload) and
   JSON.parse(undefined) throws. By route that broke the page; in a dialog it
   would have left an empty shell open. Parsing is now guarded.

The list only reloads when something was actually actioned, since reload()
re-navigates the route and would otherwise discard the checker's search on a
plain close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 18:30:24 +05:30
niteshandClaude Opus 5 d9db217b91 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>
2026-09-01 18:13:34 +05:30
anshul abc966d507 implement user, role, product, and task management components along with associated services and UI updates 2026-08-27 18:30:45 +05:30
anshul fa2522e678 Implement keyboard shortcuts and workflow changes 2026-08-25 18:51:03 +05:30
58 changed files with 3564 additions and 359 deletions
@@ -119,7 +119,7 @@
<button
mat-raised-button
color="primary"
[disabled]="!glAccountForm.valid"
[disabled]="!glAccountForm.valid || submitting"
*mifosxHasPermission="'CREATE_GLACCOUNT'"
>
{{ 'labels.buttons.Submit' | translate }}
@@ -14,6 +14,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { AccountingService } from '../../accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { PopoverService } from '../../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../../configuration-wizard/configuration-wizard.service';
@@ -42,6 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateGlAccountComponent implements OnInit, AfterViewInit {
private formBuilder = inject(UntypedFormBuilder);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService);
@@ -50,6 +52,8 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
/** GL account form. */
glAccountForm: UntypedFormGroup;
/** Submitting state to prevent duplicate submissions. */
submitting = false;
/** Chart of accounts data. */
chartOfAccountsData: any;
/** Account type data. */
@@ -166,24 +170,40 @@ export class CreateGlAccountComponent implements OnInit, AfterViewInit {
/**
* Submits the gl account form and creates gl account,
* if successful redirects to view created account.
* if successful redirects to view created account or back to list if awaiting approval.
*/
submit() {
if (this.glAccountForm.invalid) {
if (this.glAccountForm.invalid || this.submitting) {
return;
}
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe((response: any) => {
if (this.configurationWizardService.showChartofAccounts) {
this.configurationWizardService.showChartofAccounts = false;
this.openDialog();
} else {
this.router.navigate(
[
'../view',
response.resourceId
],
{ relativeTo: this.route }
);
this.submitting = true;
this.accountingService.createGlAccount(this.glAccountForm.value).subscribe({
next: (response: any) => {
if (response?.resourceId) {
if (this.configurationWizardService.showChartofAccounts) {
this.configurationWizardService.showChartofAccounts = false;
this.openDialog();
} else {
this.router.navigate(
[
'../view',
response.resourceId
],
{ relativeTo: this.route }
);
}
} else if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'GL Account submitted successfully for approval.'
});
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
} else {
this.router.navigate([this.cancelRoute], { relativeTo: this.route });
}
},
error: () => {
this.submitting = false;
}
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { Observable, of, throwError } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { map, catchError } from 'rxjs/operators';
import { environment } from 'environments/environment';
import { environment } from '../../environments/environment';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
/**
@@ -11,7 +11,7 @@ import { UntypedFormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { environment } from 'environments/environment';
import { environment } from '../../../environments/environment';
import { ClientsService } from '../clients.service';
import {
EXTERNAL_ID_STATUS_KEYS,
@@ -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 });
}
}
+20 -19
View File
@@ -31,7 +31,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'n',
route: 'navigation'
route: 'navigation',
id: 'navigation_page'
},
// Run Report - ctrl + t
{
@@ -50,17 +51,9 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'i',
route: 'checker-inbox-and-tasks/checker-inbox'
route: 'checker-inbox-and-tasks/checker-inbox',
id: 'checker_inbox'
},
// Collection Sheet- ctrl + alt + o
// {
// title: 'Collection Sheet',
// ctrlKey: true,
// shiftKey: false,
// altKey: true,
// key: 'o',
// route: ''
// },
// Create Client - ctrl + c
{
title: 'Create Client',
@@ -68,7 +61,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: true,
key: 'c',
route: 'clients/create'
route: 'clients/create',
id: 'create_client'
},
// Create Group - ctrl + g
{
@@ -77,7 +71,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'g',
route: 'groups/create'
route: 'groups/create',
id: 'create_group'
},
// Create Center - ctrl + q
{
@@ -86,7 +81,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'q',
route: 'centers/create'
route: 'centers/create',
id: 'create_center'
},
// Frequent Posting - ctrl + f
{
@@ -95,7 +91,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'f',
route: 'accounting/journal-entries/frequent-postings'
route: 'accounting/journal-entries/frequent-postings',
id: 'frequent_posting'
},
// Closure Entries - ctrl + e
{
@@ -104,7 +101,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'e',
route: 'accounting/closing-entries'
route: 'accounting/closing-entries',
id: 'closure_entries'
},
// Journal Entry - ctrl + j
{
@@ -113,7 +111,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'j',
route: 'accounting/journal-entries/create'
route: 'accounting/journal-entries/create',
id: 'journal_entry'
},
// Reports - ctrl + r
{
@@ -122,7 +121,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: false,
key: 'r',
route: 'reports'
route: 'reports',
id: 'reports_page'
},
// Accounting - ctrl + a
{
@@ -131,7 +131,8 @@ export class KeyboardShortcutsConfiguration {
shiftKey: false,
altKey: true,
key: 'a',
route: 'accounting'
route: 'accounting',
id: 'accounting_page'
},
// Save/Submit Forms - ctrl + s
{
@@ -20,6 +20,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
/** Custom Services */
import { ProductsService } from '../../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 { minNumberValueValidator } from 'app/shared/validators/min-number-value.validator';
import { maxNumberValueValidator } from 'app/shared/validators/max-number-value.validator';
@@ -45,6 +46,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateChargeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private dateUtils = inject(Dates);
@@ -352,8 +354,16 @@ export class CreateChargeComponent implements OnInit {
if (!data.maxCap) {
delete data.maxCap;
}
this.productsService.createCharge(data).subscribe((response: any) => {
this.router.navigate(['../'], { relativeTo: this.route });
this.productsService.createCharge(data).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Charge submitted successfully for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
}
});
}
}
@@ -14,6 +14,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** 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 { maxNumberValueValidator } from 'app/shared/validators/max-number-value.validator';
import { minNumberValueValidator } from 'app/shared/validators/min-number-value.validator';
import { ValidateOnFocusDirective } from '../../../directives/validate-on-focus.directive';
@@ -37,6 +38,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class EditChargeComponent implements OnInit {
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private formBuilder = inject(UntypedFormBuilder);
private route = inject(ActivatedRoute);
private router = inject(Router);
@@ -222,8 +224,16 @@ export class EditChargeComponent implements OnInit {
if (!charges.maxCap) {
delete charges.maxCap;
}
this.productsService.updateCharge(this.chargeData.id.toString(), charges).subscribe((response: any) => {
this.router.navigate(['../'], { relativeTo: this.route });
this.productsService.updateCharge(this.chargeData.id.toString(), charges).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Charge update submitted successfully for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
}
});
}
}
@@ -18,6 +18,7 @@ import { CurrentProductAccountingStepComponent } from '../current-product-steppe
import { CurrentProductPreviewStepComponent } from '../current-product-stepper/current-product-preview-step/current-product-preview-step.component';
import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Accounting } from 'app/core/utils/accounting';
import { MatTabsModule } from '@angular/material/tabs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -42,6 +43,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateCurrentProductComponent {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
@@ -144,14 +146,24 @@ export class CreateCurrentProductComponent {
submit() {
const currentProduct = this.prepareCurrentProductPayload();
console.log('Current product submit payload:', currentProduct);
this.productsService.createCurrentProduct(currentProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createCurrentProduct(currentProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Current product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
}
@@ -35,7 +35,7 @@
<p class="empty-text">No slabs defined. Click "Add Slab" to begin.</p>
} @else {
<form [formGroup]="forms[fdType]">
<table mat-table [dataSource]="slabs(fdType).controls" class="slabs-table">
<table mat-table [dataSource]="slabsDataSources[fdType]" class="slabs-table">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let row">{{ row.get('srNo').value }}</td>
@@ -139,12 +139,25 @@
</mat-form-field>
</form>
@if (getSlabValidationErrors(fdType).length > 0) {
<div class="validation-errors-box">
<mat-icon color="warn">error_outline</mat-icon>
<div class="error-list">
@for (err of getSlabValidationErrors(fdType); track err) {
<p class="error-item">{{ err }}</p>
}
</div>
</div>
}
<div class="actions-row">
<button
type="button"
mat-raised-button
color="primary"
[disabled]="forms[fdType].invalid || slabs(fdType).length === 0"
[disabled]="
forms[fdType].invalid || slabs(fdType).length === 0 || getSlabValidationErrors(fdType).length > 0
"
(click)="openPreview(fdType)"
>
Preview & Save
@@ -78,6 +78,30 @@
min-width: 200px;
}
/* ── Validation Errors ── */
.validation-errors-box {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #b91c1c;
font-size: 0.875rem;
margin-bottom: 0.5rem;
.error-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.error-item {
margin: 0;
}
}
/* ── Actions ── */
.actions-row {
display: flex;
@@ -7,13 +7,14 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { DecimalPipe } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconButton } from '@angular/material/button';
import { MatExpansionModule } from '@angular/material/expansion';
import {
MatTable,
MatTableDataSource,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
@@ -26,6 +27,7 @@ import {
} from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -54,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class BaseFdInterestRateChartComponent implements OnInit {
private fb = inject(FormBuilder);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
readonly fdTypes = [
'SINGLE',
@@ -87,6 +90,7 @@ export class BaseFdInterestRateChartComponent implements OnInit {
];
forms: Record<string, FormGroup> = {};
slabsDataSources: Record<string, MatTableDataSource<AbstractControl>> = {};
activeRates: Record<string, any> = {};
historyRates: any[] = [];
@@ -98,6 +102,7 @@ export class BaseFdInterestRateChartComponent implements OnInit {
ngOnInit(): void {
this.fdTypes.forEach((type) => {
this.forms[type] = this.buildForm();
this.slabsDataSources[type] = new MatTableDataSource<AbstractControl>([]);
this.isSaving[type] = false;
this.saveMessage[type] = '';
this.showPreview[type] = false;
@@ -111,15 +116,140 @@ export class BaseFdInterestRateChartComponent implements OnInit {
addSlab(fdType: string): void {
const arr = this.slabs(fdType);
arr.push(this.buildSlabRow({ srNo: arr.length + 1 }));
let nextFrom = 1;
let periodType = this.getDefaultPeriodType(fdType);
if (arr.length > 0) {
const lastSlab = arr.at(arr.length - 1).value;
periodType = lastSlab.periodTypeEnum ?? periodType;
if (lastSlab.toPeriod != null && lastSlab.toPeriod >= 0) {
nextFrom = Number(lastSlab.toPeriod) + 1;
} else if (lastSlab.fromPeriod != null && lastSlab.fromPeriod >= 0) {
nextFrom = Number(lastSlab.fromPeriod) + 1;
}
}
const desc = this.generateSlabDescription(nextFrom, null, periodType);
arr.push(
this.buildSlabRow({
srNo: arr.length + 1,
description: desc,
periodTypeEnum: periodType,
fromPeriod: nextFrom,
toPeriod: null,
annualInterestRate: 0,
seniorCitizenRate: 0
})
);
this.slabsDataSources[fdType].data = [...arr.controls];
}
removeSlab(fdType: string, index: number): void {
this.slabs(fdType).removeAt(index);
this.slabs(fdType).controls.forEach((c, i) => c.patchValue({ srNo: i + 1 }));
this.slabsDataSources[fdType].data = [...this.slabs(fdType).controls];
}
getDefaultPeriodType(fdType: string): number {
switch (fdType) {
case 'SINGLE':
return 0; // Days
case 'DOUBLE':
return 3; // Years
case 'SPECIAL':
return 2; // Months
default:
return 0;
}
}
generateSlabDescription(from: number, to: number | null, periodTypeEnum: number): string {
const unit = this.getPeriodLabel(periodTypeEnum);
if (to == null) {
return `${from}+ ${unit}`;
}
return `${from} to ${to} ${unit}`;
}
getSlabValidationErrors(fdType: string): string[] {
const errors: string[] = [];
const arr = this.slabs(fdType);
if (!arr || arr.length === 0) {
return errors;
}
const slabs = arr.controls.map((c) => c.value);
const expectedPeriodType = slabs[0].periodTypeEnum;
let expectedFrom = 1;
for (let i = 0; i < slabs.length; i++) {
const s = slabs[i];
const slabNum = i + 1;
if (s.periodTypeEnum !== expectedPeriodType) {
errors.push(`Slab #${slabNum}: Period type must be consistent across all slabs.`);
}
if (s.fromPeriod == null || s.fromPeriod < 0) {
errors.push(`Slab #${slabNum}: 'From' period must be a non-negative number.`);
} else if (s.fromPeriod !== expectedFrom) {
if (i === 0) {
errors.push(`Slab #${slabNum}: First slab must start from 1 (found: ${s.fromPeriod}).`);
} else if (s.fromPeriod < expectedFrom) {
errors.push(
`Slab #${slabNum}: Range overlaps with previous slab (expected start: ${expectedFrom}, found: ${s.fromPeriod}).`
);
} else {
errors.push(
`Slab #${slabNum}: Gap detected in range (expected start: ${expectedFrom}, found: ${s.fromPeriod}).`
);
}
}
if (i < slabs.length - 1) {
// Non-final slab
if (s.toPeriod == null) {
errors.push(`Slab #${slabNum}: 'To' period is required for non-final slabs.`);
} else {
if (s.toPeriod < 0) {
errors.push(`Slab #${slabNum}: 'To' period must be non-negative.`);
}
if (s.fromPeriod != null && s.toPeriod < s.fromPeriod) {
errors.push(
`Slab #${slabNum}: 'From' period (${s.fromPeriod}) cannot be greater than 'To' period (${s.toPeriod}).`
);
}
expectedFrom = Number(s.toPeriod) + 1;
}
} else {
// Final slab
if (s.toPeriod != null) {
if (s.toPeriod < 0) {
errors.push(`Slab #${slabNum}: 'To' period must be non-negative.`);
}
if (s.fromPeriod != null && s.toPeriod < s.fromPeriod) {
errors.push(
`Slab #${slabNum}: 'From' period (${s.fromPeriod}) cannot be greater than 'To' period (${s.toPeriod}).`
);
}
}
}
if (s.annualInterestRate == null || s.annualInterestRate < 0) {
errors.push(`Slab #${slabNum}: Annual Interest Rate cannot be negative.`);
}
if (s.seniorCitizenRate != null && s.seniorCitizenRate < 0) {
errors.push(`Slab #${slabNum}: Senior Citizen Rate cannot be negative.`);
}
}
return errors;
}
openPreview(fdType: string): void {
if (this.getSlabValidationErrors(fdType).length > 0) {
return;
}
this.showPreview[fdType] = true;
this.saveMessage[fdType] = '';
}
@@ -129,13 +259,24 @@ export class BaseFdInterestRateChartComponent implements OnInit {
}
confirmSave(fdType: string): void {
if (this.isSaving[fdType]) {
return;
}
this.isSaving[fdType] = true;
const { versionLabel, remarks, slabs } = this.forms[fdType].value;
this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs }).subscribe({
next: () => {
this.saveMessage[fdType] = 'New version activated. Previous version deactivated.';
this.showPreview[fdType] = false;
next: (response: any) => {
this.isSaving[fdType] = false;
this.showPreview[fdType] = false;
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Base interest rate configuration submitted successfully for approval.'
});
this.saveMessage[fdType] = 'Base interest rate configuration submitted successfully for approval.';
} else {
this.saveMessage[fdType] = 'New version activated. Previous version deactivated.';
}
this.loadAll();
},
error: () => {
@@ -171,6 +312,7 @@ export class BaseFdInterestRateChartComponent implements OnInit {
const arr = this.slabs(type);
arr.clear();
(active?.slabs ?? []).forEach((s: any) => arr.push(this.buildSlabRow(s)));
this.slabsDataSources[type].data = [...arr.controls];
this.forms[type].patchValue({ versionLabel: '', remarks: '' });
},
error: () => {
@@ -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 }
);
}
}
});
}
}
@@ -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 });
}
});
}
@@ -21,6 +21,7 @@ import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { LoanProducts } from '../loan-products';
import {
AdvancedPaymentAllocation,
@@ -63,6 +64,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateLoanProductComponent implements OnInit {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private loanProducts = inject(LoanProducts);
private router = inject(Router);
private accounting = inject(Accounting);
@@ -313,14 +315,24 @@ export class CreateLoanProductComponent implements OnInit {
delete loanProduct['daysInYearCustomStrategy'];
}
this.productsService.createLoanProduct(loanProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createLoanProduct(loanProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Loan product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
@@ -1,4 +1,4 @@
/**
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
@@ -22,6 +22,7 @@ import { SavingProductAccountingStepComponent } from '../saving-product-stepper/
/** 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 { Accounting } from 'app/core/utils/accounting';
import { SavingProductPreviewStepComponent } from '../saving-product-stepper/saving-product-preview-step/saving-product-preview-step.component';
import { MatTabsModule } from '@angular/material/tabs';
@@ -47,6 +48,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateSavingProductComponent {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
@@ -162,14 +164,24 @@ export class CreateSavingProductComponent {
submit() {
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createSavingProduct(savingProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Savings product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
}
@@ -22,6 +22,7 @@ import { ShareProductAccountingStepComponent } from '../share-product-stepper/sh
/** 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 { Accounting } from 'app/core/utils/accounting';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ShareProductPreviewStepComponent } from '../share-product-stepper/share-product-preview-step/share-product-preview-step.component';
@@ -49,6 +50,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateShareProductComponent {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router);
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
@@ -143,14 +145,24 @@ export class CreateShareProductComponent {
chargesSelected: this.shareProduct.chargesSelected.map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code // locale required for digitsAfterDecimal
};
this.productsService.createShareProduct(shareProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createShareProduct(shareProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Share product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
}
@@ -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;
}
}
}
+4
View File
@@ -7,6 +7,10 @@
-->
<div class="container m-b-10 layout-row layout-lt-md-column align-end gap-1percent">
<button mat-raised-button color="primary" class="m-r-10" (click)="configureShortcuts()">
<fa-icon icon="keyboard" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Configure Shortcuts' | translate }}
</button>
<button mat-raised-button color="primary" class="m-r-10" [routerLink]="['/system', 'roles-and-permissions']">
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Permissions' | translate }}
+8 -1
View File
@@ -28,6 +28,7 @@ import { Router, RouterLink } from '@angular/router';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { ChangePasswordDialogComponent } from 'app/shared/change-password-dialog/change-password-dialog.component';
import { SettingsService } from 'app/settings/settings.service';
import { KeyboardShortcutsDialogComponent } from 'app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -108,7 +109,13 @@ export class ProfileComponent implements OnInit {
});
}
get tenantIdentifier(): string {
get tenantIdentifier() {
return this.settingsService.tenantIdentifier || 'default';
}
configureShortcuts() {
this.dialog.open(KeyboardShortcutsDialogComponent, {
width: '800px'
});
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'environments/environment';
import { environment } from '../../environments/environment';
export interface OpenProjectWorkPackage {
id: number;
@@ -6,37 +6,205 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title>{{ 'labels.heading.Keyboard Shortcuts' | translate }}</h1>
<div class="ks-header">
<h1 mat-dialog-title class="ks-title">{{ 'labels.heading.Configure Keyboard Shortcuts' | translate }}</h1>
<button mat-icon-button (click)="openUsagePopup()" matTooltip="Shortcut Usage">
<mat-icon>analytics</mat-icon>
</button>
</div>
<div mat-dialog-content>
<div class="layout-column gap-2px">
@for (item of buttonConfig.buttonCombinations; track item) {
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill flex-50">
<span class="flex-50">
@if (item.ctrlKey) {
<span>
<span class="modifier-key">{{ 'labels.inputs.Ctrl' | translate }}</span> +
</span>
@if (validationError) {
<div class="alert error margin-b-10">
{{ validationError }}
</div>
}
<table class="ks-table">
<thead>
<tr>
<th>{{ 'labels.inputs.Shortcut Name' | translate }}</th>
<th>{{ 'Key Combination' }}</th>
</tr>
</thead>
<tbody>
@for (item of shortcuts; track item) {
<tr style="cursor: pointer" (click)="showShortcutDetails(item)">
<td [attr.colspan]="editingShortcutId === item.id || (!item.id && editingShortcutId === item.title) ? 2 : 1">
@if (editingShortcutId === item.id || (!item.id && editingShortcutId === item.title)) {
<!-- Edit Mode -->
<div class="layout-row-wrap layout-lt-md-column gap-2percent padding-y-10">
<h4 class="flex-100 margin-0 margin-b-10">Edit Shortcut</h4>
<mat-form-field class="ks-field flex-48">
<mat-label>Shortcut Name</mat-label>
<input matInput [(ngModel)]="editingShortcutData.title" [disabled]="!item.custom" />
</mat-form-field>
@if (item.custom || item.route) {
<mat-form-field class="ks-field flex-48">
<mat-label>URL / Route</mat-label>
<input matInput [(ngModel)]="editingShortcutData.route" [disabled]="!item.custom" />
</mat-form-field>
}
@if (item.custom) {
<mat-form-field class="ks-field flex-48">
<mat-label>Action</mat-label>
<mat-select [(ngModel)]="editingShortcutData.action">
<mat-option value="navigate">Navigate</mat-option>
</mat-select>
</mat-form-field>
}
<mat-form-field class="ks-field flex-48">
<mat-label>Key Combination</mat-label>
<input
matInput
readonly
[value]="formatShortcut(editingShortcutData)"
(keydown)="onKeyDown($event, editingShortcutData, true)"
placeholder="Press keys..."
/>
</mat-form-field>
<div class="flex-100 layout-row justify-content-end gap-10px">
<button mat-stroked-button class="ks-actions__cancel" (click)="cancelEditShortcut()">Cancel</button>
<button
mat-flat-button
class="ks-actions__confirm ks-actions__confirm--basic"
(click)="saveEditShortcut()"
>
Save
</button>
</div>
</div>
} @else {
<!-- View Mode -->
<span style="text-transform: capitalize; padding: 8px 0; display: inline-block">{{ item.title }}</span>
}
</td>
@if (!(editingShortcutId === item.id || (!item.id && editingShortcutId === item.title))) {
<td (click)="$event.stopPropagation()">
<mat-form-field class="ks-field" subscriptSizing="dynamic" style="width: 250px">
<input
matInput
readonly
[value]="formatShortcut(item)"
(keydown)="onKeyDown($event, item)"
(focus)="activeInputItem = item"
(blur)="activeInputItem = null"
placeholder="Click and press keys..."
matTooltip="Click here and press your desired key combination"
/>
</mat-form-field>
</td>
}
@if (item.shiftKey) {
<span>
<span class="modifier-key">{{ 'labels.inputs.Shift' | translate }}</span> +
</span>
}
@if (item.altKey) {
<span>
<span class="modifier-key">{{ 'labels.inputs.Alt' | translate }}</span> +
</span>
}
@if (item.key) {
<span>
<span class="modifier-key">{{ item.key }}</span></span
>
}
</span>
<span class="flex-50">{{ item.title }}</span>
</tr>
}
</tbody>
</table>
@if (isAddingNew) {
<div
class="layout-row-wrap layout-lt-md-column gap-2percent padding-20 mat-elevation-z1 margin-t-10"
style="background-color: white; border-radius: 4px"
>
<h4 class="flex-100 margin-0 margin-b-10">Add Custom Shortcut</h4>
<mat-form-field class="ks-field flex-48">
<mat-label>Shortcut Name</mat-label>
<input matInput [(ngModel)]="newShortcut.title" placeholder="e.g. Share Holder" />
</mat-form-field>
<mat-form-field class="ks-field flex-48">
<mat-label>URL / Route</mat-label>
<input matInput [(ngModel)]="newShortcut.route" placeholder="e.g. /shares/share-holders" />
</mat-form-field>
<mat-form-field class="ks-field flex-48">
<mat-label>Action</mat-label>
<mat-select [(ngModel)]="newShortcut.action">
<mat-option value="navigate">Navigate</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="ks-field flex-48">
<mat-label>Key Combination</mat-label>
<input
matInput
readonly
[value]="formatShortcut(newShortcut)"
(keydown)="onKeyDown($event, newShortcut, true)"
placeholder="Press keys..."
/>
</mat-form-field>
<div class="flex-100 layout-row justify-content-end gap-10px">
<button mat-stroked-button class="ks-actions__cancel" (click)="cancelAddShortcut()">Cancel</button>
<button mat-flat-button class="ks-actions__confirm ks-actions__confirm--basic" (click)="saveNewShortcut()">
Save
</button>
</div>
}
</div>
</div>
} @else {
<div class="margin-t-10">
<button mat-button color="primary" (click)="startAddShortcut()"><mat-icon>add</mat-icon> Add Shortcut</button>
</div>
}
</div>
<mat-dialog-actions class="ks-actions">
<button mat-flat-button class="ks-actions__confirm ks-actions__confirm--warn" (click)="resetToDefaults()">
Reset to Defaults
</button>
<div class="ks-actions__right">
<button mat-stroked-button class="ks-actions__cancel" (click)="cancel()">
{{ 'labels.button.cancel' | translate }}
</button>
<button
mat-flat-button
class="ks-actions__confirm ks-actions__confirm--basic"
(click)="save()"
[disabled]="validationError !== null"
>
{{ 'labels.button.save' | translate }}
</button>
</div>
</mat-dialog-actions>
<ng-template #shortcutDetailsTemplate let-data>
<h2 mat-dialog-title style="text-transform: capitalize">{{ data.title }}</h2>
<div mat-dialog-content>
<div class="layout-column gap-10px padding-y-10" style="font-size: 14px">
<div>
<strong>Shortcut Name:</strong> <br />
<span style="text-transform: capitalize">{{ data.title }}</span>
</div>
<div>
<strong>Key Combination:</strong> <br />
{{ formatShortcut(data) }}
</div>
<div>
<strong>Action:</strong> <br />
{{ data.action === 'navigate' ? 'Navigate' : data.action || data.id || 'Predefined' }}
</div>
@if (data.route) {
<div>
<strong>URL / Route:</strong> <br />
{{ data.route }}
</div>
}
<div>
<strong>Description:</strong> <br />
{{ getShortcutDescription(data) }}
</div>
</div>
</div>
<mat-dialog-actions align="end">
<button mat-button color="primary" (click)="startEditShortcut(data)">Edit</button>
<button mat-button color="warn" (click)="deleteShortcut(data)">Delete</button>
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
</ng-template>
@@ -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;
}
}
}
@@ -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<KeyboardShortcutsDialogComponent>>(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<ShortcutItem> = {};
activeNewInput = false;
editingShortcutId: string | null = null;
editingShortcutData: Partial<ShortcutItem> = {};
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<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, item: Partial<ShortcutItem>, 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<string>();
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<any> | 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<ShortcutItem>): 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<ShortcutItem>, 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<string>();
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);
}
}
@@ -0,0 +1,22 @@
<!--
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="suggestion-container">
<div class="suggestion-text">
<strong
>💡 You frequently use <span style="text-transform: capitalize">{{ data.pageName }}</span></strong
><br />
Would you like to create a keyboard shortcut for faster access?
</div>
<div class="suggestion-actions margin-t-10" style="display: flex; justify-content: flex-end; gap: 8px">
<button mat-button style="color: #bbb" (click)="dismiss()">Not Now</button>
<button mat-button style="color: #fff; background-color: rgba(255, 255, 255, 0.1)" (click)="createShortcut()">
Create Shortcut
</button>
</div>
</div>
@@ -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;
}
@@ -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<ShortcutSuggestionSnackbarComponent>);
data: { pageName: string; route: string } = inject(MAT_SNACK_BAR_DATA);
createShortcut() {
this.snackBarRef.dismissWithAction();
}
dismiss() {
this.snackBarRef.dismiss();
}
}
@@ -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!
}
});
}
}
@@ -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 });
@@ -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,
@@ -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 {
@@ -155,12 +155,7 @@
<td mat-cell *matCellDef="let makerChecker">{{ makerChecker.entityName }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id, 'view']"
class="table-row"
></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" (click)="viewEntry(row)" class="table-row"></tr>
</table>
</div>
}
@@ -33,6 +33,7 @@ import { SettingsService } from 'app/settings/settings.service';
/** Dialog Components */
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { ViewCheckerInboxComponent } from '../../view-checker-inbox/view-checker-inbox.component';
import { Dates } from 'app/core/utils/dates';
import { TranslateService } from '@ngx-translate/core';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -123,7 +124,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')
);
}
/**
@@ -286,6 +289,27 @@ export class CheckerInboxComponent implements OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/**
* Opens one inbox entry in a popup rather than navigating to its own page, so the checker keeps
* the list (and any search/filter they set up) behind the dialog. The direct route stays available
* for a bookmarked link — ViewCheckerInboxComponent supports both entry points.
*/
viewEntry(row: any): void {
this.dialog
.open(ViewCheckerInboxComponent, {
data: { checkerId: row.id },
autoFocus: false
})
.afterClosed()
.subscribe((result: any) => {
// Only reload when something was actually actioned — a plain close must not throw away the
// checker's current search, which reload() does by re-navigating the route.
if (result?.refreshed) {
this.reload();
}
});
}
/**
* Refetches data for the component
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
@@ -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/.
-->
<div class="pce-tab-content">
<div class="pce-action-bar">
<div class="pce-search-box">
<mat-icon>search</mat-icon>
<input #filterInput type="text" placeholder="Search by username" (keyup.enter)="applyFilter(filterInput.value)" />
</div>
<select class="pce-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="pce-fb-sep"></span>
<div class="pce-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="pce-preset-chip"
[class.pce-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<button type="button" class="pce-btn pce-btn-refresh" (click)="load()" [disabled]="loading">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading&hellip;</p>
</div>
} @else if (loaded && requests.length === 0) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No {{ filterStatus | lowercase }} user requests</p>
<p class="empty-text">No pending user operations found for this filter.</p>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="requests" class="modern-table">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let req">{{ req.id }}</td>
</ng-container>
<ng-container matColumnDef="requestType">
<th mat-header-cell *matHeaderCellDef>Action</th>
<td mat-cell *matCellDef="let req">
<span
class="type-badge"
[class.type-create]="req.requestType === 'CREATE_USER'"
[class.type-update]="req.requestType === 'UPDATE_USER'"
>
{{ req.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="username">
<th mat-header-cell *matHeaderCellDef>Username</th>
<td mat-cell *matCellDef="let req" class="font-medium">{{ req.username || '—' }}</td>
</ng-container>
<ng-container matColumnDef="fullname">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let req">{{ req.firstname || '' }} {{ req.lastname || '' }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let req">{{ req.email || '—' }}</td>
</ng-container>
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef>Office ID</th>
<td mat-cell *matCellDef="let req">{{ req.officeId || '—' }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let req">{{ req.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let req">{{ formatDate(req.submittedDate) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let req">
<span class="status-badge" [ngClass]="statusClass(req.status)">{{ req.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let req">
<div class="action-buttons">
<button
type="button"
class="btn-action btn-action--compare"
(click)="compare(req)"
title="View / Compare Details"
>
<fa-icon icon="eye"></fa-icon>
<span>View</span>
</button>
@if (req.status === 'PENDING') {
<button
type="button"
class="btn-action btn-action--approve"
(click)="approve(req)"
title="Approve User Request"
>
<fa-icon icon="check"></fa-icon>
<span>Approve</span>
</button>
<button
type="button"
class="btn-action btn-action--reject"
(click)="reject(req)"
title="Reject User Request"
>
<fa-icon icon="times"></fa-icon>
<span>Reject</span>
</button>
}
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="[10, 20, 50]"
(page)="onPageChange($event)"
></mat-paginator>
</div>
}
</div>
@@ -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;
}
}
@@ -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<string, string> = {
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();
});
}
});
}
}
@@ -0,0 +1,75 @@
<!--
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/.
-->
<h2 mat-dialog-title class="dialog-title">
<span class="dialog-title__action"
>{{ data.requestType === 'CREATE_USER' ? 'Create User' : 'Update User' }} Request</span
>
<span class="dialog-title__user">&mdash; {{ data.username }}</span>
</h2>
<mat-dialog-content class="dialog-body">
<div class="meta-banner">
<div class="meta-item">
<span class="meta-label">Request ID</span>
<span class="meta-val">#{{ data.requestId }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Submitted By</span>
<span class="meta-val">{{ data.makerUsername }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Submitted On</span>
<span class="meta-val">{{ data.submittedDate }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Status</span>
<span
class="status-badge"
[class.badge-pending]="data.status === 'PENDING'"
[class.badge-approved]="data.status === 'APPROVED'"
[class.badge-rejected]="data.status === 'REJECTED'"
>
{{ data.status }}
</span>
</div>
</div>
@if (data.rejectReason) {
<div class="reject-banner"><strong>Rejection Reason:</strong> {{ data.rejectReason }}</div>
}
<table class="compare-table">
<thead>
<tr>
<th>Field</th>
@if (data.requestType === 'UPDATE_USER') {
<th>Current Value</th>
}
<th>Proposed Value</th>
</tr>
</thead>
<tbody>
@for (r of data.rows; track r.key) {
<tr>
<td class="col-field">{{ r.label }}</td>
@if (data.requestType === 'UPDATE_USER') {
<td class="col-curr">{{ r.current != null && r.current !== '' ? r.current : '—' }}</td>
}
<td class="col-prop" [class.highlight-change]="data.requestType === 'UPDATE_USER' && r.current !== r.value">
{{ r.value != null && r.value !== '' ? r.value : '—' }}
</td>
</tr>
}
</tbody>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close class="btn-close">Close</button>
</mat-dialog-actions>
@@ -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;
}
@@ -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<UserEditCompareDialogComponent>>(MatDialogRef);
data = inject<UserEditCompareDialogData>(MAT_DIALOG_DATA);
}
@@ -119,6 +119,15 @@
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span>
</a>
<a
class="ac-tab"
routerLink="./pending-user-edits"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
<span>Pending User Approvals</span>
</a>
<a
class="ac-tab"
routerLink="./rejected-account"
+6
View File
@@ -36,6 +36,7 @@ import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-t
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component';
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
import { PendingUserEditsComponent } from './checker-inbox-and-tasks-tabs/pending-user-edits/pending-user-edits.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
/** Custom Resolvers */
@@ -168,6 +169,11 @@ const routes: Routes = [
component: PendingClientEditsComponent,
data: { title: 'Pending Client Edits' }
},
{
path: 'pending-user-edits',
component: PendingUserEditsComponent,
data: { title: 'Pending User Approvals' }
},
{
path: 'withdrawal-requests',
component: WithdrawalRequestsComponent,
@@ -6,108 +6,121 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (isProductEditEntry) {
<div class="container m-b-20">
<div class="mat-elevation-z1" style="padding: 12px 16px; border-left: 4px solid #f59e0b; background: #fffbeb">
This is a product edit awaiting approval. Approve or reject it from
<strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
<div class="vci" [class.vci--dialog]="isDialog">
<!-- The heading lives here in both modes, so a dialog does not end up showing it twice. -->
<div class="vci-head">
<span class="vci-head__title">{{ 'labels.heading.View Checker Inbox' | translate }}</span>
@if (isDialog) {
<button type="button" class="vci-head__close" (click)="close()">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Close' | translate }}</span>
</button>
}
</div>
<!-- As a dialog the detail is fetched rather than resolved, so nothing below can render until it
arrives — every field reads checkerInboxDetail.* directly. -->
@if (loading) {
<div class="vci-loading">
<fa-icon icon="spinner" class="vci-loading__ic"></fa-icon>
<span>{{ 'labels.heading.Loading' | translate }}…</span>
</div>
</div>
} @else {
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
<button mat-raised-button color="success" (click)="approveChecker()">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
}
}
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap responsive-column">
<h2 class="mat-h2 flex-fill">{{ 'labels.heading.View Checker Inbox' | translate }}</h2>
<mat-divider [inset]="true"></mat-divider>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.id }}</span>
@if (checkerInboxDetail) {
<div class="vci-body">
@if (isProductEditEntry) {
<div class="vci-notice">
This is a product edit awaiting approval. Approve or reject it from
<strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Status' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.processingResult }}</span>
} @else {
<div class="vci-actions">
<button type="button" class="vci-btn vci-btn--approve" (click)="approveChecker()">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Approve' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--delete" (click)="deleteChecker()">
<fa-icon icon="trash"></fa-icon><span>{{ 'labels.buttons.Delete' | translate }}</span>
</button>
<button type="button" class="vci-btn vci-btn--reject" (click)="rejectChecker()">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Reject' | translate }}</span>
</button>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.User' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.maker }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Action' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.actionName }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Entity' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.entityName }}</span>
</div>
@if (checkerInboxDetail.resourceId) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resource ID' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.resourceId }}</span>
<div class="vci-section">
<div class="vci-section__title">{{ 'labels.heading.Details' | translate }}</div>
<!-- A real label/value grid rather than the global flex-40/flex-60 utilities, which are sized
for a full-width page and collapse into one run-together line inside a dialog. -->
<dl class="vci-grid">
<div class="vci-kv">
<dt>{{ 'labels.inputs.ID' | translate }}</dt>
<dd>{{ checkerInboxDetail.id }}</dd>
</div>
}
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Date' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.madeOnDate | dateFormat }}</span>
</div>
@if (checkerInboxDetail.officeName) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Office' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.officeName }}</span>
<div class="vci-kv">
<dt>{{ 'labels.inputs.Status' | translate }}</dt>
<dd>{{ checkerInboxDetail.processingResult || '—' }}</dd>
</div>
}
@if (checkerInboxDetail.savingsAccountNo) {
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Savings Account Number' | translate }}:</span>
<span class="flex-60">{{ checkerInboxDetail.savingsAccountNo }}</span>
<div class="vci-kv">
<dt>{{ 'labels.inputs.User' | translate }}</dt>
<dd>{{ checkerInboxDetail.maker || '—' }}</dd>
</div>
}
@if (checkerInboxDetail.groupLevelName) {
<div class="flex-fill">
<span class="flex-40"> {{ checkerInboxDetail.groupLevelName }}</span>
<span class="flex-60">{{ checkerInboxDetail.groupName }}</span>
<div class="vci-kv">
<dt>{{ 'labels.inputs.Action' | translate }}</dt>
<dd>
<span class="vci-tag" [class.vci-tag--danger]="checkerInboxDetail.actionName === 'DELETE'">{{
checkerInboxDetail.actionName
}}</span>
</dd>
</div>
}
@if (displayJSONData) {
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Command' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
@for (json of jsondata | keyvalue; track json) {
<div class="flex-fill">
<span class="flex-40">{{ json.key }}:</span>
<span class="flex-60">{{ json.value }}</span>
<div class="vci-kv">
<dt>{{ 'labels.inputs.Entity' | translate }}</dt>
<dd>{{ checkerInboxDetail.entityName }}</dd>
</div>
@if (checkerInboxDetail.resourceId) {
<div class="vci-kv">
<dt>{{ 'labels.inputs.Resource ID' | translate }}</dt>
<dd>{{ checkerInboxDetail.resourceId }}</dd>
</div>
}
}
<div class="vci-kv">
<dt>{{ 'labels.inputs.Date' | translate }}</dt>
<dd>{{ checkerInboxDetail.madeOnDate | dateFormat }}</dd>
</div>
@if (checkerInboxDetail.officeName) {
<div class="vci-kv">
<dt>{{ 'labels.inputs.Office' | translate }}</dt>
<dd>{{ checkerInboxDetail.officeName }}</dd>
</div>
}
@if (checkerInboxDetail.savingsAccountNo) {
<div class="vci-kv">
<dt>{{ 'labels.inputs.Savings Account Number' | translate }}</dt>
<dd>{{ checkerInboxDetail.savingsAccountNo }}</dd>
</div>
}
@if (checkerInboxDetail.groupLevelName) {
<div class="vci-kv">
<dt>{{ checkerInboxDetail.groupLevelName }}</dt>
<dd>{{ checkerInboxDetail.groupName }}</dd>
</div>
}
</dl>
</div>
</mat-card-content>
</mat-card>
@if (displayJSONData) {
<div class="vci-section">
<div class="vci-section__title">{{ 'labels.heading.Command' | translate }}</div>
<dl class="vci-grid">
@for (json of jsondata | keyvalue; track json.key) {
<div class="vci-kv">
<dt>{{ json.key }}</dt>
<dd>{{ json.value }}</dd>
</div>
}
</dl>
</div>
}
</div>
}
</div>
@@ -6,35 +6,240 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.mat-elevation-z1 {
margin: 1em 0 1.5em;
$success: #16a34a;
$danger: #dc2626;
$warning: #d97706;
$text-dark: #0d1b2a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-page: #f8fafc;
:host {
display: block;
}
h2,
h3,
h4 {
.vci {
background: #fff;
/* Opened as a popup rather than a full page: sized to its own content, capped so a long command
payload scrolls inside the dialog instead of pushing it off screen. As a route it stays a
normal page-width card. */
&--dialog {
width: min(680px, 92vw);
max-height: 85vh;
overflow-y: auto;
border-radius: 10px;
}
&:not(&--dialog) {
max-width: 900px;
margin: 20px auto;
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
}
}
// ── Header ───────────────────────────────────────────────────────────────────
.vci-head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid $border;
background: #fff;
position: sticky;
top: 0;
z-index: 1;
&__title {
font-size: 15px;
font-weight: 700;
color: $text-dark;
}
&__close {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: none;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: $text-soft;
cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.15s;
&:hover {
background: #f1f5f9;
color: $text-mid;
}
}
}
.vci-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 24px;
font-size: 14px;
color: $text-soft;
&__ic {
animation: vci-spin 1s linear infinite;
}
}
@keyframes vci-spin {
to {
transform: rotate(360deg);
}
}
.vci-body {
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Actions ──────────────────────────────────────────────────────────────────
.vci-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.vci-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 36px;
padding: 0 18px;
border-radius: 8px;
font-family: inherit;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
border: 1.5px solid transparent;
transition: all 0.15s;
&--approve {
background: #f0fdf4;
color: $success;
border-color: #86efac;
&:hover {
background: $success;
color: #fff;
border-color: $success;
}
}
&--delete {
background: $danger;
color: #fff;
border-color: $danger;
&:hover {
filter: brightness(1.08);
}
}
&--reject {
background: #fff;
color: $text-mid;
border-color: $border;
&:hover {
background: #f1f5f9;
color: $text-dark;
}
}
}
.vci-notice {
padding: 12px 16px;
border-left: 4px solid $warning;
background: #fffbeb;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
color: #92400e;
}
// ── Detail sections ──────────────────────────────────────────────────────────
.vci-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
&__title {
padding: 10px 16px;
background: $bg-page;
border-bottom: 1px solid $border;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: $text-mid;
}
}
/* One label above its value, two pairs per row where there is room. This replaces the global
flex-40/flex-60 utilities, which are sized for a full-width page and ran every field together
onto a single line inside the dialog. */
.vci-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px 24px;
margin: 0;
font-weight: 500;
padding: 16px;
}
span {
margin: 0.5em 0;
.vci-kv {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
dt {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $text-soft;
}
dd {
margin: 0;
font-size: 14px;
font-weight: 500;
color: $text-dark;
line-height: 1.35;
overflow-wrap: anywhere;
}
}
.margin-t {
margin-top: 1em;
}
.vci-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
background: #e0f2fe;
color: #0369a1;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.04em;
mat-divider {
margin: 0 0 0.5em;
}
.mat-raised-button.mat-success {
color: #fff;
background-color: #008000ab;
}
.mat-raised-button.mat-reject {
color: #fff;
background-color: #ffca00;
&--danger {
background: #fee2e2;
color: #991b1b;
}
}
@@ -10,7 +10,7 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import * as _ from 'lodash';
import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
/** Custom Services */
import { TasksService } from '../tasks.service';
@@ -19,7 +19,6 @@ import { TasksService } from '../tasks.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { TranslateService } from '@ngx-translate/core';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDivider } from '@angular/material/divider';
import { KeyValuePipe } from '@angular/common';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -31,7 +30,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatDivider,
KeyValuePipe,
DateFormatPipe
]
@@ -42,6 +40,17 @@ export class ViewCheckerInboxComponent {
private router = inject(Router);
private translateService = inject(TranslateService);
private tasksService = inject(TasksService);
/** Present only when opened as a popup from the Checker Inbox list; absent when reached by direct
* route (a bookmarked or shared link). Both entry points stay supported — this only changes where
* the detail comes from and how "done" is signalled: closing the dialog vs. navigating back. */
private dialogRef = inject(MatDialogRef<ViewCheckerInboxComponent>, { optional: true });
private dialogData = inject<{ checkerId: number | string } | null>(MAT_DIALOG_DATA, { optional: true });
loading = false;
get isDialog(): boolean {
return !!this.dialogRef;
}
/** Checker Inbox Details Data */
checkerInboxDetail: any;
@@ -65,7 +74,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'
);
}
/**
@@ -76,13 +88,51 @@ export class ViewCheckerInboxComponent {
* @param {TasksService} tasksService Tasks Service.
*/
constructor() {
// As a popup the list only has summary rows, so the detail (which carries commandAsJson) is
// fetched here; as a route it is already resolved by GetCheckerInboxDetailResolver.
if (this.dialogData?.checkerId != null) {
this.loading = true;
this.tasksService.getCheckerInboxDetail(this.dialogData.checkerId).subscribe((detail: any) => {
this.loading = false;
this.applyDetail(detail);
});
return;
}
this.route.data.subscribe((data: { checkerInboxDetail: any }) => {
this.checkerInboxDetail = data.checkerInboxDetail;
this.jsondata = JSON.parse(this.checkerInboxDetail.commandAsJson);
this.displayJSONData = !_.isEmpty(this.jsondata);
this.applyDetail(data.checkerInboxDetail);
});
}
private applyDetail(detail: any): void {
this.checkerInboxDetail = detail;
// commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined)
// throws — which as a route only broke the page, but in a dialog would leave an empty shell open.
try {
this.jsondata = detail?.commandAsJson ? JSON.parse(detail.commandAsJson) : {};
} catch {
this.jsondata = {};
}
this.displayJSONData = !_.isEmpty(this.jsondata);
}
/** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
* dialog, or returns to the inbox route otherwise. */
private finish(): void {
if (this.dialogRef) {
this.dialogRef.close({ refreshed: true });
} else {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
close(): void {
if (this.dialogRef) {
this.dialogRef.close();
} else {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
/**
* Approve Checker
*/
@@ -96,7 +146,7 @@ export class ViewCheckerInboxComponent {
approveCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'approve').subscribe((res: any) => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.finish();
});
}
});
@@ -115,7 +165,7 @@ export class ViewCheckerInboxComponent {
rejectCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.tasksService.executeMakerCheckerAction(this.checkerInboxDetail.id, 'reject').subscribe((res: any) => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.finish();
});
}
});
@@ -134,7 +184,7 @@ export class ViewCheckerInboxComponent {
deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.tasksService.deleteMakerChecker(this.checkerInboxDetail.id).subscribe((res: any) => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.finish();
});
}
});
@@ -21,6 +21,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { UsersService } from '../users.service';
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Dialog Component */
import { PasswordsUtility } from 'app/core/utils/passwords-utility';
@@ -56,6 +57,7 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private dialog = inject(MatDialog);
private passwordsUtility = inject(PasswordsUtility);
private alertService = inject(AlertService);
/** User form. */
userForm: UntypedFormGroup;
@@ -211,6 +213,12 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
if (this.configurationWizardService.showUsersForm) {
this.configurationWizardService.showUsersForm = false;
this.openDialog();
} else if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'User creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
+31 -21
View File
@@ -19,6 +19,7 @@ import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit User Component.
@@ -39,6 +40,7 @@ export class EditUserComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** User Data */
userData: any;
@@ -142,27 +144,35 @@ export class EditUserComponent implements OnInit {
const editedUser = this.editUserForm.value;
this.usersService.editUser(this.userData.id, editedUser).subscribe({
next: (response: any) => {
// 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);
+58
View File
@@ -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<any>} Pending users
*/
getPendingUsers(
status?: string,
username?: string,
fromDate?: string,
toDate?: string,
offset: number = 0,
limit: number = 20
): Observable<any> {
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<any>}
*/
getPendingUser(id: string | number): Observable<any> {
return this.http.get(`/users/pending/${id}`);
}
/**
* @param {string | number} id Pending request ID
* @returns {Observable<any>}
*/
approvePendingUser(id: string | number): Observable<any> {
return this.http.post(`/users/pending/${id}/approve`, {});
}
/**
* @param {string | number} id Pending request ID
* @param {string} reason Rejection reason
* @returns {Observable<any>}
*/
rejectPendingUser(id: string | number, reason: string): Observable<any> {
return this.http.post(`/users/pending/${id}/reject`, { reason });
}
}
@@ -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']);
});
}
+7 -47
View File
@@ -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 });
}
}
}
}
+6
View File
@@ -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;
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260901',
'hash': '9739ff07'
'hash': '03f2f1d7'
},
'allow_switching_backend_instance': true
};
+3
View File
@@ -8,6 +8,9 @@
],
"app/*": [
"src/app/*"
],
"environments/*": [
"src/environments/*"
]
},
"importHelpers": true,