side menu issue fix and user role multi select done

This commit is contained in:
Surendiran 2026-06-22 15:24:35 +05:30
parent 1ea70bf93a
commit fa310dd87c
12 changed files with 766 additions and 167 deletions

View File

@ -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;

View File

@ -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);
}); });
} }

View File

@ -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),
),
], ],
), ),
); );

View File

@ -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();

View File

@ -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'),
),
], ],
), ),
), ),

View File

@ -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,
),
], ],
); );
} }

View File

@ -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);

View 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,
);
}
}

View File

@ -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;
} }

View File

@ -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,
], ],
); );
} }

View File

@ -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,
),
); );
} }
} }

View 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();
}
}