side menu issue fix and user role multi select done
This commit is contained in:
parent
1ea70bf93a
commit
fa310dd87c
@ -1,31 +1,108 @@
|
|||||||
|
import '../constants/enums.dart';
|
||||||
import '../errors/failure.dart';
|
import '../errors/failure.dart';
|
||||||
import '../network/api_handler.dart';
|
import '../network/api_handler.dart';
|
||||||
import '../../core/constants/enums.dart';
|
|
||||||
|
/// Maps UI / route module keys to API permission module codes.
|
||||||
|
const Map<String, String> permissionModuleAliases = {
|
||||||
|
'master_data': 'MASTERS',
|
||||||
|
'masters': 'MASTERS',
|
||||||
|
'users': 'USERS',
|
||||||
|
'roles': 'ROLES',
|
||||||
|
'assets': 'ASSET',
|
||||||
|
'asset': 'ASSET',
|
||||||
|
'asset_categories': 'ASSET',
|
||||||
|
'asset_allocations': 'ASSET',
|
||||||
|
'asset_maintenance': 'ASSET',
|
||||||
|
'asset_disposal': 'ASSET',
|
||||||
|
'vendor': 'VENDOR',
|
||||||
|
'purchase_order': 'PURCHASE_ORDER',
|
||||||
|
'grn': 'GRN',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Modules that are always visible in navigation (no API permission key).
|
||||||
|
const Set<String> alwaysVisibleMenuModules = {'dashboard'};
|
||||||
|
|
||||||
|
String normalizePermissionModule(String module) {
|
||||||
|
final normalized = module.trim().toLowerCase().replaceAll('-', '_');
|
||||||
|
return permissionModuleAliases[normalized] ?? normalized.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> permissionActionAliases(PermissionAction action) {
|
||||||
|
return switch (action) {
|
||||||
|
PermissionAction.read => const ['read', 'view'],
|
||||||
|
PermissionAction.update => const ['update', 'edit'],
|
||||||
|
PermissionAction.create => const ['create'],
|
||||||
|
PermissionAction.delete => const ['delete'],
|
||||||
|
PermissionAction.export => const ['export'],
|
||||||
|
PermissionAction.approve => const ['approve'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
bool hasPermission({
|
bool hasPermission({
|
||||||
required List<String> userPermissions,
|
required List<String> userPermissions,
|
||||||
required String module,
|
required String module,
|
||||||
required PermissionAction action,
|
required PermissionAction action,
|
||||||
}) {
|
}) {
|
||||||
final normalizedModule = module.trim().toLowerCase();
|
|
||||||
final actionValue = action.value;
|
|
||||||
|
|
||||||
final candidates = {
|
|
||||||
'$normalizedModule.$actionValue',
|
|
||||||
'$normalizedModule:$actionValue',
|
|
||||||
'${normalizedModule.toUpperCase()}:$actionValue',
|
|
||||||
'${normalizedModule.toUpperCase()}:${actionValue.toUpperCase()}',
|
|
||||||
'${module.toUpperCase()}:$actionValue',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userPermissions.contains('*')) return true;
|
if (userPermissions.contains('*')) return true;
|
||||||
|
|
||||||
for (final key in candidates) {
|
final moduleKey = normalizePermissionModule(module);
|
||||||
|
final lowerModule = moduleKey.toLowerCase();
|
||||||
|
|
||||||
|
for (final actionAlias in permissionActionAliases(action)) {
|
||||||
|
final candidates = {
|
||||||
|
'$lowerModule.$actionAlias',
|
||||||
|
'$lowerModule:$actionAlias',
|
||||||
|
'$moduleKey:$actionAlias',
|
||||||
|
'$moduleKey:${actionAlias.toUpperCase()}',
|
||||||
|
'${module.trim().toUpperCase()}:$actionAlias',
|
||||||
|
'${module.trim().toUpperCase()}:${actionAlias.toUpperCase()}',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final key in candidates) {
|
||||||
|
if (userPermissions.contains(key)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final wildcards = {
|
||||||
|
'$lowerModule.*',
|
||||||
|
'$lowerModule:*',
|
||||||
|
'$moduleKey:*',
|
||||||
|
'${module.trim().toUpperCase()}:*',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final key in wildcards) {
|
||||||
if (userPermissions.contains(key)) return true;
|
if (userPermissions.contains(key)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return userPermissions.contains('$normalizedModule.*') ||
|
return false;
|
||||||
userPermissions.contains('${normalizedModule.toUpperCase()}:*');
|
}
|
||||||
|
|
||||||
|
bool canSeeMenuModule({
|
||||||
|
required List<String> permissions,
|
||||||
|
required String module,
|
||||||
|
required UserRole role,
|
||||||
|
}) {
|
||||||
|
if (alwaysVisibleMenuModules.contains(module)) return true;
|
||||||
|
if (isSuperAdmin(role) || permissions.contains('*')) return true;
|
||||||
|
|
||||||
|
if (module == 'users') {
|
||||||
|
return hasPermission(
|
||||||
|
userPermissions: permissions,
|
||||||
|
module: 'users',
|
||||||
|
action: PermissionAction.read,
|
||||||
|
) ||
|
||||||
|
hasPermission(
|
||||||
|
userPermissions: permissions,
|
||||||
|
module: 'roles',
|
||||||
|
action: PermissionAction.read,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPermission(
|
||||||
|
userPermissions: permissions,
|
||||||
|
module: module,
|
||||||
|
action: PermissionAction.read,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isSuperAdmin(UserRole role) => role == UserRole.superAdmin;
|
bool isSuperAdmin(UserRole role) => role == UserRole.superAdmin;
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
final loginResponse = await remote.login(request);
|
final loginResponse = await remote.login(request);
|
||||||
await _persistSession(loginResponse);
|
await _persistSession(loginResponse);
|
||||||
final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken);
|
final user = await remote.getCurrentUser();
|
||||||
return loginResponse.copyWith(user: user);
|
return loginResponse.copyWith(user: user);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -55,13 +55,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<UserModel>> getCurrentUser() async {
|
Future<Result<UserModel>> getCurrentUser() async {
|
||||||
return safeApiCall(() async {
|
return safeApiCall(() => remote.getCurrentUser());
|
||||||
final accessToken = await tokenStorage.getAccessToken();
|
|
||||||
if (accessToken == null || accessToken.isEmpty) {
|
|
||||||
throw const FormatException('No access token');
|
|
||||||
}
|
|
||||||
return remote.resolveCurrentUser(accessToken);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -90,7 +84,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
final loginResponse = await remote.verifyOtp(request);
|
final loginResponse = await remote.verifyOtp(request);
|
||||||
await _persistSession(loginResponse);
|
await _persistSession(loginResponse);
|
||||||
final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken);
|
final user = await remote.getCurrentUser();
|
||||||
return loginResponse.copyWith(user: user);
|
return loginResponse.copyWith(user: user);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
@ -150,6 +152,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
|
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
|
||||||
final end = (page * pageSize).clamp(0, total);
|
final end = (page * pageSize).clamp(0, total);
|
||||||
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
|
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
|
||||||
|
final canCreate = ref.can('masters', PermissionAction.create);
|
||||||
|
final canExport = ref.can('masters', PermissionAction.export);
|
||||||
|
final canEdit = ref.can('masters', PermissionAction.update);
|
||||||
|
final canDelete = ref.can('masters', PermissionAction.delete);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -164,11 +170,12 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
label: const Text('All Masters'),
|
label: const Text('All Masters'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
if (canCreate)
|
||||||
onPressed: () => _openFormPanel(),
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
onPressed: () => _openFormPanel(),
|
||||||
label: Text('Add ${def.title}'),
|
icon: const Icon(Icons.add),
|
||||||
),
|
label: Text('Add ${def.title}'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -194,6 +201,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
searchHint: _searchHint(def),
|
searchHint: _searchHint(def),
|
||||||
isExporting: state.isExporting,
|
isExporting: state.isExporting,
|
||||||
|
showExport: canExport,
|
||||||
onSearch: notifier.setSearch,
|
onSearch: notifier.setSearch,
|
||||||
onExport: _exportRecords,
|
onExport: _exportRecords,
|
||||||
);
|
);
|
||||||
@ -223,6 +231,8 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
definition: def,
|
definition: def,
|
||||||
items: state.items,
|
items: state.items,
|
||||||
isDeleting: state.isDeleting,
|
isDeleting: state.isDeleting,
|
||||||
|
canEdit: canEdit,
|
||||||
|
canDelete: canDelete,
|
||||||
onEdit: (id) => _openFormPanel(recordId: id),
|
onEdit: (id) => _openFormPanel(recordId: id),
|
||||||
onDelete: _deleteRecord,
|
onDelete: _deleteRecord,
|
||||||
),
|
),
|
||||||
@ -303,6 +313,8 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
required this.definition,
|
required this.definition,
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.isDeleting,
|
required this.isDeleting,
|
||||||
|
required this.canEdit,
|
||||||
|
required this.canDelete,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
@ -310,6 +322,8 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
final MasterDefinition definition;
|
final MasterDefinition definition;
|
||||||
final List<Map<String, dynamic>> items;
|
final List<Map<String, dynamic>> items;
|
||||||
final bool isDeleting;
|
final bool isDeleting;
|
||||||
|
final bool canEdit;
|
||||||
|
final bool canDelete;
|
||||||
final ValueChanged<String> onEdit;
|
final ValueChanged<String> onEdit;
|
||||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||||
|
|
||||||
@ -444,20 +458,22 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
actionsCell: Row(
|
actionsCell: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
if (canEdit)
|
||||||
tooltip: 'Edit',
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
tooltip: 'Edit',
|
||||||
onPressed: id == null ? null : () => onEdit(id),
|
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||||
),
|
onPressed: id == null ? null : () => onEdit(id),
|
||||||
IconButton(
|
),
|
||||||
tooltip: 'Delete',
|
if (canDelete)
|
||||||
icon: Icon(
|
IconButton(
|
||||||
Icons.delete_outline,
|
tooltip: 'Delete',
|
||||||
size: 20,
|
icon: Icon(
|
||||||
color: theme.colorScheme.error,
|
Icons.delete_outline,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: isDeleting ? null : () => onDelete(row),
|
||||||
),
|
),
|
||||||
onPressed: isDeleting ? null : () => onDelete(row),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
|
|
||||||
class MastersHubScreen extends StatelessWidget {
|
class MastersHubScreen extends ConsumerWidget {
|
||||||
const MastersHubScreen({super.key});
|
const MastersHubScreen({super.key});
|
||||||
|
|
||||||
static const double _tileMaxWidth = 120;
|
static const double _tileMaxWidth = 120;
|
||||||
static const double _tileHeight = 92;
|
static const double _tileHeight = 92;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final categories = masterCategories;
|
final categories = masterCategories;
|
||||||
|
final canViewMasters = ref.can('masters', PermissionAction.read);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -37,6 +41,7 @@ class MastersHubScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
...categories.map((category) {
|
...categories.map((category) {
|
||||||
|
if (!canViewMasters) return const SizedBox.shrink();
|
||||||
final items = masterDefinitions
|
final items = masterDefinitions
|
||||||
.where((def) => def.category == category)
|
.where((def) => def.category == category)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../core/utils/formatters.dart';
|
import '../../../../core/utils/formatters.dart';
|
||||||
|
|
||||||
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../core/theme/theme_provider.dart';
|
import '../../../../core/theme/theme_provider.dart';
|
||||||
@ -9,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
|
|||||||
import '../../../../shared/models/permission_matrix_models.dart';
|
import '../../../../shared/models/permission_matrix_models.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/utils/file_download_helper.dart';
|
import '../../../../shared/utils/file_download_helper.dart';
|
||||||
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.dart';
|
import '../../../../shared/widgets/app_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
@ -142,6 +144,11 @@ class _UsersRoleManagementScreenState
|
|||||||
summary?.rolesCount ??
|
summary?.rolesCount ??
|
||||||
state.roles.length;
|
state.roles.length;
|
||||||
final isCompact = context.isMobile || MediaQuery.sizeOf(context).width < 900;
|
final isCompact = context.isMobile || MediaQuery.sizeOf(context).width < 900;
|
||||||
|
final canCreateUser = ref.can('users', PermissionAction.create);
|
||||||
|
final canCreateRole = ref.can('roles', PermissionAction.create);
|
||||||
|
final canViewUsers = ref.can('users', PermissionAction.read);
|
||||||
|
final canViewRoles = ref.can('roles', PermissionAction.read);
|
||||||
|
final canEditRoles = ref.can('roles', PermissionAction.update);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.all(isCompact ? 16 : 24),
|
padding: EdgeInsets.all(isCompact ? 16 : 24),
|
||||||
@ -150,6 +157,8 @@ class _UsersRoleManagementScreenState
|
|||||||
children: [
|
children: [
|
||||||
_Header(
|
_Header(
|
||||||
compact: isCompact,
|
compact: isCompact,
|
||||||
|
canCreateRole: canCreateRole,
|
||||||
|
canCreateUser: canCreateUser,
|
||||||
onNewRole: _openCreateRole,
|
onNewRole: _openCreateRole,
|
||||||
onAddUser: _openAddUser,
|
onAddUser: _openAddUser,
|
||||||
),
|
),
|
||||||
@ -217,26 +226,32 @@ class _UsersRoleManagementScreenState
|
|||||||
state: state,
|
state: state,
|
||||||
userCount: summary?.totalUsers ?? usersState?.total ?? state.totalUsers,
|
userCount: summary?.totalUsers ?? usersState?.total ?? state.totalUsers,
|
||||||
roleCount: roleCount,
|
roleCount: roleCount,
|
||||||
|
canViewUsers: canViewUsers,
|
||||||
|
canViewRoles: canViewRoles,
|
||||||
|
canEditRoles: canEditRoles,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: IndexedStack(
|
child: IndexedStack(
|
||||||
index: switch (state.selectedTab) {
|
index: _tabIndex(
|
||||||
RbacTab.users => 0,
|
state.selectedTab,
|
||||||
RbacTab.roles => 1,
|
canViewUsers: canViewUsers,
|
||||||
RbacTab.permissions => 2,
|
canViewRoles: canViewRoles,
|
||||||
},
|
canEditRoles: canEditRoles,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
_UsersTab(
|
if (canViewUsers)
|
||||||
onAddUser: _openAddUser,
|
_UsersTab(
|
||||||
onEditUser: (user) => _openUserPanel(userId: user.id),
|
onAddUser: _openAddUser,
|
||||||
),
|
onEditUser: (user) => _openUserPanel(userId: user.id),
|
||||||
_RolesTab(
|
),
|
||||||
onNewRole: _openCreateRole,
|
if (canViewRoles)
|
||||||
onEditRole: _editRole,
|
_RolesTab(
|
||||||
onDeleteRole: _deleteRole,
|
onNewRole: _openCreateRole,
|
||||||
),
|
onEditRole: _editRole,
|
||||||
const _PermissionMatrixTab(),
|
onDeleteRole: _deleteRole,
|
||||||
|
),
|
||||||
|
if (canEditRoles) const _PermissionMatrixTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -246,14 +261,33 @@ class _UsersRoleManagementScreenState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _tabIndex(
|
||||||
|
RbacTab tab, {
|
||||||
|
required bool canViewUsers,
|
||||||
|
required bool canViewRoles,
|
||||||
|
required bool canEditRoles,
|
||||||
|
}) {
|
||||||
|
final tabs = <RbacTab>[
|
||||||
|
if (canViewUsers) RbacTab.users,
|
||||||
|
if (canViewRoles) RbacTab.roles,
|
||||||
|
if (canEditRoles) RbacTab.permissions,
|
||||||
|
];
|
||||||
|
final index = tabs.indexOf(tab);
|
||||||
|
return index < 0 ? 0 : index;
|
||||||
|
}
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
class _Header extends StatelessWidget {
|
||||||
const _Header({
|
const _Header({
|
||||||
required this.compact,
|
required this.compact,
|
||||||
|
required this.canCreateRole,
|
||||||
|
required this.canCreateUser,
|
||||||
required this.onNewRole,
|
required this.onNewRole,
|
||||||
required this.onAddUser,
|
required this.onAddUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
final bool canCreateRole;
|
||||||
|
final bool canCreateUser;
|
||||||
final VoidCallback onNewRole;
|
final VoidCallback onNewRole;
|
||||||
final VoidCallback onAddUser;
|
final VoidCallback onAddUser;
|
||||||
|
|
||||||
@ -282,16 +316,18 @@ class _Header extends StatelessWidget {
|
|||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
if (canCreateRole)
|
||||||
onPressed: onNewRole,
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.add_circle_outline, size: 18),
|
onPressed: onNewRole,
|
||||||
label: const Text('New Role'),
|
icon: const Icon(Icons.add_circle_outline, size: 18),
|
||||||
),
|
label: const Text('New Role'),
|
||||||
ElevatedButton.icon(
|
),
|
||||||
onPressed: onAddUser,
|
if (canCreateUser)
|
||||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
ElevatedButton.icon(
|
||||||
label: const Text('Add User'),
|
onPressed: onAddUser,
|
||||||
),
|
icon: const Icon(Icons.person_add_outlined, size: 18),
|
||||||
|
label: const Text('Add User'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -317,11 +353,17 @@ class _TabBar extends ConsumerWidget {
|
|||||||
required this.state,
|
required this.state,
|
||||||
required this.userCount,
|
required this.userCount,
|
||||||
required this.roleCount,
|
required this.roleCount,
|
||||||
|
required this.canViewUsers,
|
||||||
|
required this.canViewRoles,
|
||||||
|
required this.canEditRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RbacState state;
|
final RbacState state;
|
||||||
final int userCount;
|
final int userCount;
|
||||||
final int roleCount;
|
final int roleCount;
|
||||||
|
final bool canViewUsers;
|
||||||
|
final bool canViewRoles;
|
||||||
|
final bool canEditRoles;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -329,25 +371,28 @@ class _TabBar extends ConsumerWidget {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_TabButton(
|
if (canViewUsers)
|
||||||
label: 'Users ($userCount)',
|
_TabButton(
|
||||||
icon: Icons.people_outline,
|
label: 'Users ($userCount)',
|
||||||
selected: state.selectedTab == RbacTab.users,
|
icon: Icons.people_outline,
|
||||||
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users),
|
selected: state.selectedTab == RbacTab.users,
|
||||||
),
|
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users),
|
||||||
_TabButton(
|
),
|
||||||
label: 'Roles ($roleCount)',
|
if (canViewRoles)
|
||||||
icon: Icons.shield_outlined,
|
_TabButton(
|
||||||
selected: state.selectedTab == RbacTab.roles,
|
label: 'Roles ($roleCount)',
|
||||||
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles),
|
icon: Icons.shield_outlined,
|
||||||
),
|
selected: state.selectedTab == RbacTab.roles,
|
||||||
_TabButton(
|
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles),
|
||||||
label: 'Permission Matrix',
|
),
|
||||||
icon: Icons.vpn_key_outlined,
|
if (canEditRoles)
|
||||||
selected: state.selectedTab == RbacTab.permissions,
|
_TabButton(
|
||||||
onTap: () =>
|
label: 'Permission Matrix',
|
||||||
ref.read(rbacProvider.notifier).setTab(RbacTab.permissions),
|
icon: Icons.vpn_key_outlined,
|
||||||
),
|
selected: state.selectedTab == RbacTab.permissions,
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(rbacProvider.notifier).setTab(RbacTab.permissions),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -596,6 +641,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
),
|
),
|
||||||
data: (usersState) {
|
data: (usersState) {
|
||||||
final filters = usersState.filters;
|
final filters = usersState.filters;
|
||||||
|
final canExport = ref.can('users', PermissionAction.export);
|
||||||
|
final canEditUser = ref.can('users', PermissionAction.update);
|
||||||
|
final canDeleteUser = ref.can('users', PermissionAction.delete);
|
||||||
final roles = ['All roles', ...?filters?.roles.map((r) => r.name)];
|
final roles = ['All roles', ...?filters?.roles.map((r) => r.name)];
|
||||||
final departments = [
|
final departments = [
|
||||||
'All departments',
|
'All departments',
|
||||||
@ -640,6 +688,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
departments: departments,
|
departments: departments,
|
||||||
statuses: statuses,
|
statuses: statuses,
|
||||||
isExporting: usersState.isExporting,
|
isExporting: usersState.isExporting,
|
||||||
|
showExport: canExport,
|
||||||
onExport: _exportUsers,
|
onExport: _exportUsers,
|
||||||
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||||
onRoleChanged: (value) {
|
onRoleChanged: (value) {
|
||||||
@ -758,6 +807,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
UserTableActionsCell(
|
UserTableActionsCell(
|
||||||
child: UserTableActions(
|
child: UserTableActions(
|
||||||
user: user,
|
user: user,
|
||||||
|
canEdit: canEditUser,
|
||||||
|
canResetPassword: canEditUser,
|
||||||
|
canDeactivate: canDeleteUser,
|
||||||
onEdit: () => _editUser(user),
|
onEdit: () => _editUser(user),
|
||||||
onResetPassword: () =>
|
onResetPassword: () =>
|
||||||
_resetPassword(user),
|
_resetPassword(user),
|
||||||
@ -854,6 +906,7 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onExport,
|
required this.onExport,
|
||||||
this.isExporting = false,
|
this.isExporting = false,
|
||||||
|
this.showExport = true,
|
||||||
required this.onRoleChanged,
|
required this.onRoleChanged,
|
||||||
required this.onDepartmentChanged,
|
required this.onDepartmentChanged,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
@ -869,6 +922,7 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final VoidCallback onExport;
|
final VoidCallback onExport;
|
||||||
final bool isExporting;
|
final bool isExporting;
|
||||||
|
final bool showExport;
|
||||||
final ValueChanged<String> onRoleChanged;
|
final ValueChanged<String> onRoleChanged;
|
||||||
final ValueChanged<String> onDepartmentChanged;
|
final ValueChanged<String> onDepartmentChanged;
|
||||||
final ValueChanged<String> onStatusChanged;
|
final ValueChanged<String> onStatusChanged;
|
||||||
@ -916,13 +970,18 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final visibleFilters = [
|
||||||
|
...filters.take(3),
|
||||||
|
if (showExport) filters[3],
|
||||||
|
];
|
||||||
|
|
||||||
if (wrapped) {
|
if (wrapped) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
searchField,
|
searchField,
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: filters),
|
Wrap(spacing: 12, runSpacing: 12, children: visibleFilters),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -936,8 +995,10 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
Expanded(flex: 2, child: filters[1]),
|
Expanded(flex: 2, child: filters[1]),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: filters[2]),
|
Expanded(child: filters[2]),
|
||||||
const SizedBox(width: 12),
|
if (showExport) ...[
|
||||||
filters[3],
|
const SizedBox(width: 12),
|
||||||
|
filters[3],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -987,6 +1048,9 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final rolesAsync = ref.watch(rolesListProvider);
|
final rolesAsync = ref.watch(rolesListProvider);
|
||||||
|
final canCreateRole = ref.can('roles', PermissionAction.create);
|
||||||
|
final canEditRole = ref.can('roles', PermissionAction.update);
|
||||||
|
final canDeleteRole = ref.can('roles', PermissionAction.delete);
|
||||||
// Text styles are resolved at build time; rebuild cards when theme toggles.
|
// Text styles are resolved at build time; rebuild cards when theme toggles.
|
||||||
final themeMode = ref.watch(themeModeProvider);
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
final brightness = Theme.of(context).brightness;
|
final brightness = Theme.of(context).brightness;
|
||||||
@ -1014,9 +1078,9 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
),
|
),
|
||||||
itemCount: roles.length + 1,
|
itemCount: roles.length + (canCreateRole ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == roles.length) {
|
if (canCreateRole && index == roles.length) {
|
||||||
return AppHoverEffect(
|
return AppHoverEffect(
|
||||||
onTap: onNewRole,
|
onTap: onNewRole,
|
||||||
showHoverBorder: false,
|
showHoverBorder: false,
|
||||||
@ -1069,12 +1133,13 @@ class _RolesTab extends ConsumerWidget {
|
|||||||
child: Icon(appearance.icon, color: appearance.color, size: 20),
|
child: Icon(appearance.icon, color: appearance.color, size: 20),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
if (canEditRole)
|
||||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
IconButton(
|
||||||
onPressed: () => onEditRole(role),
|
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||||
visualDensity: VisualDensity.compact,
|
onPressed: () => onEditRole(role),
|
||||||
),
|
visualDensity: VisualDensity.compact,
|
||||||
if (!isProtectedRole(role))
|
),
|
||||||
|
if (canDeleteRole && !isProtectedRole(role))
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
onPressed: () => onDeleteRole(role),
|
onPressed: () => onDeleteRole(role),
|
||||||
@ -1202,6 +1267,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final rolesAsync = ref.watch(rolesListProvider);
|
final rolesAsync = ref.watch(rolesListProvider);
|
||||||
final catalogAsync = ref.watch(permissionCatalogProvider);
|
final catalogAsync = ref.watch(permissionCatalogProvider);
|
||||||
|
final canEditRoles = ref.can('roles', PermissionAction.update);
|
||||||
final selectedRoleId = _selectedRoleId;
|
final selectedRoleId = _selectedRoleId;
|
||||||
final matrixAsync = selectedRoleId == null
|
final matrixAsync = selectedRoleId == null
|
||||||
? null
|
? null
|
||||||
@ -1272,22 +1338,23 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
if (canEditRoles)
|
||||||
onPressed: selectedRoleId == null || _isSaving
|
ElevatedButton.icon(
|
||||||
? null
|
onPressed: selectedRoleId == null || _isSaving
|
||||||
: () => _save(selectedRole?.name ?? 'role'),
|
? null
|
||||||
icon: _isSaving
|
: () => _save(selectedRole?.name ?? 'role'),
|
||||||
? const SizedBox(
|
icon: _isSaving
|
||||||
width: 18,
|
? const SizedBox(
|
||||||
height: 18,
|
width: 18,
|
||||||
child: CircularProgressIndicator(
|
height: 18,
|
||||||
strokeWidth: 2,
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
strokeWidth: 2,
|
||||||
),
|
color: Colors.white,
|
||||||
)
|
),
|
||||||
: const Icon(Icons.save_outlined, size: 18),
|
)
|
||||||
label: Text(_isSaving ? 'Saving...' : 'Save changes'),
|
: const Icon(Icons.save_outlined, size: 18),
|
||||||
),
|
label: Text(_isSaving ? 'Saving...' : 'Save changes'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -192,6 +192,9 @@ class UserTableActions extends StatelessWidget {
|
|||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onResetPassword,
|
required this.onResetPassword,
|
||||||
required this.onDeactivate,
|
required this.onDeactivate,
|
||||||
|
this.canEdit = true,
|
||||||
|
this.canResetPassword = true,
|
||||||
|
this.canDeactivate = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const columnWidth = 120.0;
|
static const columnWidth = 120.0;
|
||||||
@ -200,6 +203,9 @@ class UserTableActions extends StatelessWidget {
|
|||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onResetPassword;
|
final VoidCallback onResetPassword;
|
||||||
final VoidCallback onDeactivate;
|
final VoidCallback onDeactivate;
|
||||||
|
final bool canEdit;
|
||||||
|
final bool canResetPassword;
|
||||||
|
final bool canDeactivate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -209,24 +215,27 @@ class UserTableActions extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
_UserActionIcon(
|
if (canEdit)
|
||||||
tooltip: 'Edit user',
|
_UserActionIcon(
|
||||||
icon: Icons.edit_outlined,
|
tooltip: 'Edit user',
|
||||||
color: muted,
|
icon: Icons.edit_outlined,
|
||||||
onPressed: onEdit,
|
color: muted,
|
||||||
),
|
onPressed: onEdit,
|
||||||
_UserActionIcon(
|
),
|
||||||
tooltip: 'Reset password',
|
if (canResetPassword)
|
||||||
icon: Icons.vpn_key_outlined,
|
_UserActionIcon(
|
||||||
color: muted,
|
tooltip: 'Reset password',
|
||||||
onPressed: onResetPassword,
|
icon: Icons.vpn_key_outlined,
|
||||||
),
|
color: muted,
|
||||||
_UserActionIcon(
|
onPressed: onResetPassword,
|
||||||
tooltip: 'Deactivate user',
|
),
|
||||||
icon: Icons.person_off_outlined,
|
if (canDeactivate)
|
||||||
color: muted,
|
_UserActionIcon(
|
||||||
onPressed: onDeactivate,
|
tooltip: 'Deactivate user',
|
||||||
),
|
icon: Icons.person_off_outlined,
|
||||||
|
color: muted,
|
||||||
|
onPressed: onDeactivate,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,10 +28,14 @@ class UserModel with _$UserModel {
|
|||||||
|
|
||||||
/// Parses user objects returned by auth endpoints (`/auth/login`, `/auth/me`).
|
/// Parses user objects returned by auth endpoints (`/auth/login`, `/auth/me`).
|
||||||
factory UserModel.fromLoginJson(Map<String, dynamic> json) {
|
factory UserModel.fromLoginJson(Map<String, dynamic> json) {
|
||||||
final role = json['role_name'] ??
|
final roleRaw = json['role_name'] ??
|
||||||
json['role'] ??
|
json['role'] ??
|
||||||
json['role_slug'] ??
|
json['role_slug'] ??
|
||||||
|
_firstRoleFromList(json['roles']) ??
|
||||||
'employee';
|
'employee';
|
||||||
|
final role = roleRaw is Map
|
||||||
|
? (roleRaw['name'] as String? ?? roleRaw['slug'] as String? ?? 'employee')
|
||||||
|
: roleRaw.toString();
|
||||||
final name = json['full_name'] ??
|
final name = json['full_name'] ??
|
||||||
json['name'] ??
|
json['name'] ??
|
||||||
[
|
[
|
||||||
@ -41,12 +45,13 @@ class UserModel with _$UserModel {
|
|||||||
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
id: json['id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
employeeId: (json['employee_code'] ?? json['employee_id'] ?? '') as String,
|
employeeId: (json['employee_code'] ?? json['employee_id'] ?? '').toString(),
|
||||||
name: name is String && name.isNotEmpty ? name : (json['email'] as String? ?? ''),
|
name: name is String && name.isNotEmpty ? name : (json['email'] as String? ?? ''),
|
||||||
email: json['email'] as String? ?? '',
|
email: json['email'] as String? ?? '',
|
||||||
mobile: json['mobile']?.toString() ?? '',
|
mobile: json['mobile']?.toString() ?? '',
|
||||||
role: role is String ? role : role.toString(),
|
role: role,
|
||||||
department: json['department_name'] as String? ?? json['department'] as String?,
|
department: _nestedLabel(json['department_name']) ??
|
||||||
|
_nestedLabel(json['department']),
|
||||||
status: json['status'] as String? ?? 'active',
|
status: json['status'] as String? ?? 'active',
|
||||||
companyId: json['company_id']?.toString(),
|
companyId: json['company_id']?.toString(),
|
||||||
branchId: json['branch_id']?.toString(),
|
branchId: json['branch_id']?.toString(),
|
||||||
@ -56,15 +61,32 @@ class UserModel with _$UserModel {
|
|||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
createdAt: json['created_at'] != null
|
createdAt: json['created_at'] != null
|
||||||
? DateTime.tryParse(json['created_at'] as String)
|
? DateTime.tryParse(json['created_at'].toString())
|
||||||
: null,
|
: null,
|
||||||
updatedAt: json['updated_at'] != null
|
updatedAt: json['updated_at'] != null
|
||||||
? DateTime.tryParse(json['updated_at'] as String)
|
? DateTime.tryParse(json['updated_at'].toString())
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object? _firstRoleFromList(Object? roles) {
|
||||||
|
if (roles is! List || roles.isEmpty) return null;
|
||||||
|
return roles.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _nestedLabel(Object? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is String) return value.isEmpty ? null : value;
|
||||||
|
if (value is Map) {
|
||||||
|
final name = value['name'];
|
||||||
|
if (name is String && name.isNotEmpty) return name;
|
||||||
|
final code = value['code'];
|
||||||
|
if (code is String && code.isNotEmpty) return code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
extension UserModelX on UserModel {
|
extension UserModelX on UserModel {
|
||||||
UserRole get userRole => UserRole.fromValue(role);
|
UserRole get userRole => UserRole.fromValue(role);
|
||||||
EntityStatus get entityStatus => EntityStatus.fromValue(status);
|
EntityStatus get entityStatus => EntityStatus.fromValue(status);
|
||||||
|
|||||||
29
lib/shared/providers/permissions_provider.dart
Normal file
29
lib/shared/providers/permissions_provider.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/enums.dart';
|
||||||
|
import '../../core/utils/permission_utils.dart';
|
||||||
|
import 'auth_provider.dart';
|
||||||
|
|
||||||
|
final userPermissionsProvider = Provider<List<String>>((ref) {
|
||||||
|
return ref.watch(authStateProvider).user?.permissions ?? const [];
|
||||||
|
});
|
||||||
|
|
||||||
|
extension PermissionCheck on WidgetRef {
|
||||||
|
bool can(String module, PermissionAction action) {
|
||||||
|
return hasPermission(
|
||||||
|
userPermissions: read(userPermissionsProvider),
|
||||||
|
module: module,
|
||||||
|
action: action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PermissionCheckReader on Ref {
|
||||||
|
bool can(String module, PermissionAction action) {
|
||||||
|
return hasPermission(
|
||||||
|
userPermissions: read(userPermissionsProvider),
|
||||||
|
module: module,
|
||||||
|
action: action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -132,14 +132,47 @@ List<MenuItem> getVisibleMenuItems({
|
|||||||
return appMenuItems;
|
return appMenuItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
return appMenuItems.where((item) {
|
final visible = <MenuItem>[];
|
||||||
|
|
||||||
|
for (final item in appMenuItems) {
|
||||||
if (item.requiredRole != null && !isSuperAdmin(role) && role != item.requiredRole) {
|
if (item.requiredRole != null && !isSuperAdmin(role) && role != item.requiredRole) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
return hasPermission(
|
|
||||||
userPermissions: permissions,
|
if (item.children.isNotEmpty) {
|
||||||
|
final visibleChildren = item.children
|
||||||
|
.where(
|
||||||
|
(child) => canSeeMenuModule(
|
||||||
|
permissions: permissions,
|
||||||
|
module: child.module,
|
||||||
|
role: role,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (visibleChildren.isEmpty) continue;
|
||||||
|
visible.add(
|
||||||
|
MenuItem(
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
route: visibleChildren.first.route,
|
||||||
|
module: item.module,
|
||||||
|
children: visibleChildren,
|
||||||
|
requiredRole: item.requiredRole,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSeeMenuModule(
|
||||||
|
permissions: permissions,
|
||||||
module: item.module,
|
module: item.module,
|
||||||
action: PermissionAction.read,
|
role: role,
|
||||||
) || isAdmin(role);
|
)) {
|
||||||
}).toList();
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
this.wrapped = false,
|
this.wrapped = false,
|
||||||
this.searchController,
|
this.searchController,
|
||||||
this.searchWidth = 320,
|
this.searchWidth = 320,
|
||||||
|
this.showExport = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String searchHint;
|
final String searchHint;
|
||||||
@ -20,6 +21,7 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
final bool wrapped;
|
final bool wrapped;
|
||||||
final TextEditingController? searchController;
|
final TextEditingController? searchController;
|
||||||
final double searchWidth;
|
final double searchWidth;
|
||||||
|
final bool showExport;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -50,13 +52,15 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
searchField,
|
searchField,
|
||||||
const SizedBox(height: 12),
|
if (showExport) ...[
|
||||||
Row(
|
const SizedBox(height: 12),
|
||||||
children: [
|
Row(
|
||||||
const Spacer(),
|
children: [
|
||||||
exportButton,
|
const Spacer(),
|
||||||
],
|
exportButton,
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -65,7 +69,7 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(width: searchWidth, child: searchField),
|
SizedBox(width: searchWidth, child: searchField),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
exportButton,
|
if (showExport) exportButton,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@ -93,7 +95,8 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
(themeMode == ThemeModeOption.system && !isDark);
|
(themeMode == ThemeModeOption.system && !isDark);
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: AppConstants.animationDuration,
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
width: widget.isDrawer
|
width: widget.isDrawer
|
||||||
? double.infinity
|
? double.infinity
|
||||||
: (widget.collapsed ? _sidebarCollapsedWidth : _sidebarExpandedWidth),
|
: (widget.collapsed ? _sidebarCollapsedWidth : _sidebarExpandedWidth),
|
||||||
@ -141,6 +144,15 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
else
|
else
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
..._mainMenuItems.map((item) {
|
..._mainMenuItems.map((item) {
|
||||||
|
if (isNarrow && item.children.isNotEmpty) {
|
||||||
|
return _CollapsedFlyoutNavItem(
|
||||||
|
item: item,
|
||||||
|
selected: _isGroupActive(item),
|
||||||
|
isChildSelected: _isSelected,
|
||||||
|
onChildTap: widget.onItemTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (item.children.isNotEmpty && !isNarrow) {
|
if (item.children.isNotEmpty && !isNarrow) {
|
||||||
final expanded = _isGroupExpanded(item);
|
final expanded = _isGroupExpanded(item);
|
||||||
final active = _isGroupActive(item);
|
final active = _isGroupActive(item);
|
||||||
@ -178,10 +190,6 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
(isNarrow && _isGroupActive(item)),
|
(isNarrow && _isGroupActive(item)),
|
||||||
collapsed: isNarrow,
|
collapsed: isNarrow,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (item.children.isNotEmpty && isNarrow) {
|
|
||||||
widget.onItemTap(item.children.first.route);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
widget.onItemTap(item.route);
|
widget.onItemTap(item.route);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -330,7 +338,11 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _UserAvatar(name: name),
|
child: _UserProfileMenu(
|
||||||
|
userName: name,
|
||||||
|
menuOffset: const Offset(-8, -210),
|
||||||
|
child: _UserAvatar(name: name),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -378,6 +390,296 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CollapsedFlyoutNavItem extends StatefulWidget {
|
||||||
|
const _CollapsedFlyoutNavItem({
|
||||||
|
required this.item,
|
||||||
|
required this.selected,
|
||||||
|
required this.isChildSelected,
|
||||||
|
required this.onChildTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final menu.MenuItem item;
|
||||||
|
final bool selected;
|
||||||
|
final bool Function(String route) isChildSelected;
|
||||||
|
final void Function(String route) onChildTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CollapsedFlyoutNavItem> createState() =>
|
||||||
|
_CollapsedFlyoutNavItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
|
||||||
|
final _anchorKey = GlobalKey();
|
||||||
|
OverlayEntry? _overlayEntry;
|
||||||
|
Timer? _hideTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hideTimer?.cancel();
|
||||||
|
_removeOverlay();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelHide() {
|
||||||
|
_hideTimer?.cancel();
|
||||||
|
_hideTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleHide() {
|
||||||
|
_cancelHide();
|
||||||
|
_hideTimer = Timer(const Duration(milliseconds: 50), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
_removeOverlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeOverlay() {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOverlay() {
|
||||||
|
if (_overlayEntry != null) return;
|
||||||
|
_insertOverlay();
|
||||||
|
if (_overlayEntry == null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _overlayEntry != null) return;
|
||||||
|
_insertOverlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertOverlay() {
|
||||||
|
if (_overlayEntry != null) return;
|
||||||
|
|
||||||
|
final renderBox =
|
||||||
|
_anchorKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox == null) return;
|
||||||
|
|
||||||
|
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||||
|
final anchorSize = renderBox.size;
|
||||||
|
const panelWidth = 220.0;
|
||||||
|
|
||||||
|
_overlayEntry = OverlayEntry(
|
||||||
|
builder: (overlayContext) {
|
||||||
|
final theme = Theme.of(overlayContext);
|
||||||
|
final primary = theme.colorScheme.primary;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: anchorTopLeft.dx,
|
||||||
|
top: anchorTopLeft.dy,
|
||||||
|
child: TapRegion(
|
||||||
|
onTapOutside: (_) => _removeOverlay(),
|
||||||
|
child: MouseRegion(
|
||||||
|
onEnter: (_) => _cancelHide(),
|
||||||
|
onExit: (_) => _scheduleHide(),
|
||||||
|
child: _SidebarFlyoutFade(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: anchorSize.width,
|
||||||
|
height: anchorSize.height,
|
||||||
|
),
|
||||||
|
Material(
|
||||||
|
elevation: 8,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
shadowColor: Colors.black45,
|
||||||
|
child: SizedBox(
|
||||||
|
width: panelWidth,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
|
||||||
|
child: Text(
|
||||||
|
widget.item.label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
...widget.item.children.map((child) {
|
||||||
|
final childSelected =
|
||||||
|
widget.isChildSelected(child.route);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
_removeOverlay();
|
||||||
|
widget.onChildTap(child.route);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
height: _sidebarItemHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: _sidebarItemPadding,
|
||||||
|
),
|
||||||
|
color: childSelected
|
||||||
|
? primary.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
child.icon,
|
||||||
|
size: _sidebarIconSize,
|
||||||
|
color: childSelected
|
||||||
|
? primary
|
||||||
|
: theme
|
||||||
|
.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
child.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: childSelected
|
||||||
|
? primary
|
||||||
|
: theme.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
fontWeight: childSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final primary = theme.colorScheme.primary;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) {
|
||||||
|
_cancelHide();
|
||||||
|
_showOverlay();
|
||||||
|
},
|
||||||
|
onExit: (_) {
|
||||||
|
if (_overlayEntry != null) return;
|
||||||
|
_scheduleHide();
|
||||||
|
},
|
||||||
|
child: KeyedSubtree(
|
||||||
|
key: _anchorKey,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _showOverlay,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
height: _sidebarItemHeight,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.selected || _overlayEntry != null
|
||||||
|
? primary.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.item.icon,
|
||||||
|
size: 22,
|
||||||
|
color: widget.selected || _overlayEntry != null
|
||||||
|
? primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidebarFlyoutFade extends StatefulWidget {
|
||||||
|
const _SidebarFlyoutFade({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SidebarFlyoutFade> createState() => _SidebarFlyoutFadeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _fade;
|
||||||
|
late final Animation<Offset> _slide;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
);
|
||||||
|
final curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||||
|
_fade = curve;
|
||||||
|
_slide = Tween<Offset>(
|
||||||
|
begin: const Offset(-0.04, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curve);
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fade,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slide,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionLabel extends StatelessWidget {
|
class _SectionLabel extends StatelessWidget {
|
||||||
const _SectionLabel({required this.label});
|
const _SectionLabel({required this.label});
|
||||||
|
|
||||||
@ -437,7 +739,7 @@ class _SidebarNavItem extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: label,
|
message: label,
|
||||||
waitDuration: const Duration(milliseconds: 500),
|
waitDuration: const Duration(milliseconds: 250),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@ -589,19 +891,25 @@ class _UserAvatar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserProfileMenu extends ConsumerWidget {
|
class _UserProfileMenu extends ConsumerWidget {
|
||||||
const _UserProfileMenu({this.userName});
|
const _UserProfileMenu({
|
||||||
|
this.userName,
|
||||||
|
this.child,
|
||||||
|
this.menuOffset,
|
||||||
|
});
|
||||||
|
|
||||||
final String? userName;
|
final String? userName;
|
||||||
|
final Widget? child;
|
||||||
|
final Offset? menuOffset;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
icon: Icon(
|
tooltip: userName ?? 'Account menu',
|
||||||
Icons.more_vert,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
offset: menuOffset ?? Offset.zero,
|
||||||
|
position: child != null
|
||||||
|
? PopupMenuPosition.over
|
||||||
|
: PopupMenuPosition.under,
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'profile':
|
case 'profile':
|
||||||
@ -622,6 +930,12 @@ class _UserProfileMenu extends ConsumerWidget {
|
|||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
const PopupMenuItem(value: 'logout', child: Text('Logout')),
|
||||||
],
|
],
|
||||||
|
child: child ??
|
||||||
|
Icon(
|
||||||
|
Icons.more_vert,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
lib/shared/widgets/can_permission.dart
Normal file
29
lib/shared/widgets/can_permission.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/constants/enums.dart';
|
||||||
|
import '../providers/permissions_provider.dart';
|
||||||
|
|
||||||
|
/// Shows [child] only when the logged-in user has the given permission.
|
||||||
|
class CanPermission extends ConsumerWidget {
|
||||||
|
const CanPermission({
|
||||||
|
super.key,
|
||||||
|
required this.module,
|
||||||
|
required this.action,
|
||||||
|
required this.child,
|
||||||
|
this.fallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String module;
|
||||||
|
final PermissionAction action;
|
||||||
|
final Widget child;
|
||||||
|
final Widget? fallback;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
if (ref.can(module, action)) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return fallback ?? const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user