import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; import '../../models/ticket_message.dart'; import '../../models/user_office.dart'; import '../../providers/admin_user_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/user_offices_provider.dart'; import '../../widgets/responsive_body.dart'; class UserManagementScreen extends ConsumerStatefulWidget { const UserManagementScreen({super.key}); @override ConsumerState createState() => _UserManagementScreenState(); } class _UserManagementScreenState extends ConsumerState { static const List _roles = [ 'standard', 'dispatcher', 'it_staff', 'admin', ]; final _fullNameController = TextEditingController(); String? _selectedUserId; String? _selectedRole; Set _selectedOfficeIds = {}; AdminUserStatus? _selectedStatus; bool _isSaving = false; bool _isStatusLoading = false; final Map _statusCache = {}; final Set _statusLoading = {}; final Set _statusErrors = {}; Set _prefetchedUserIds = {}; @override void dispose() { _fullNameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isAdmin = ref.watch(isAdminProvider); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); final assignmentsAsync = ref.watch(userOfficesProvider); final messagesAsync = ref.watch(ticketMessagesAllProvider); return Scaffold( body: ResponsiveBody( maxWidth: 1080, child: !isAdmin ? const Center(child: Text('Admin access required.')) : _buildContent( context, profilesAsync, officesAsync, assignmentsAsync, messagesAsync, ), ), ); } Widget _buildContent( BuildContext context, AsyncValue> profilesAsync, AsyncValue> officesAsync, AsyncValue> assignmentsAsync, AsyncValue> messagesAsync, ) { if (profilesAsync.isLoading || officesAsync.isLoading || assignmentsAsync.isLoading || messagesAsync.isLoading) { return const Center(child: CircularProgressIndicator()); } if (profilesAsync.hasError || officesAsync.hasError || assignmentsAsync.hasError || messagesAsync.hasError) { final error = profilesAsync.error ?? officesAsync.error ?? assignmentsAsync.error ?? messagesAsync.error ?? 'Unknown error'; return Center(child: Text('Failed to load data: $error')); } final profiles = profilesAsync.valueOrNull ?? []; final offices = officesAsync.valueOrNull ?? []; final assignments = assignmentsAsync.valueOrNull ?? []; final messages = messagesAsync.valueOrNull ?? []; _prefetchStatuses(profiles); final lastActiveByUser = {}; for (final message in messages) { final senderId = message.senderId; if (senderId == null) continue; final current = lastActiveByUser[senderId]; if (current == null || message.createdAt.isAfter(current)) { lastActiveByUser[senderId] = message.createdAt; } } return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'User Management', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 16), Expanded( child: _buildUserTable( context, profiles, offices, assignments, lastActiveByUser, ), ), ], ), ); } Widget _buildUserTable( BuildContext context, List profiles, List offices, List assignments, Map lastActiveByUser, ) { if (profiles.isEmpty) { return const Center(child: Text('No users found.')); } final officeNameById = { for (final office in offices) office.id: office.name, }; final officeCountByUser = {}; for (final assignment in assignments) { officeCountByUser.update( assignment.userId, (value) => value + 1, ifAbsent: () => 1, ); } return Material( color: Theme.of(context).colorScheme.surface, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 720), child: SingleChildScrollView( child: DataTable( headingRowHeight: 46, dataRowMinHeight: 48, dataRowMaxHeight: 64, columnSpacing: 24, horizontalMargin: 16, dividerThickness: 1, headingRowColor: MaterialStateProperty.resolveWith( (states) => Theme.of(context).colorScheme.surfaceVariant, ), columns: const [ DataColumn(label: Text('User')), DataColumn(label: Text('Email')), DataColumn(label: Text('Role')), DataColumn(label: Text('Offices')), DataColumn(label: Text('Status')), DataColumn(label: Text('Last active')), ], rows: profiles.asMap().entries.map((entry) { final index = entry.key; final profile = entry.value; final label = profile.fullName.isEmpty ? profile.id : profile.fullName; final status = _statusCache[profile.id]; final hasError = _statusErrors.contains(profile.id); final isLoading = _statusLoading.contains(profile.id); final email = hasError ? 'Unavailable' : (status?.email ?? (isLoading ? 'Loading...' : 'N/A')); final statusLabel = hasError ? 'Unavailable' : (status == null ? (isLoading ? 'Loading...' : 'Unknown') : (status.isLocked ? 'Locked' : 'Active')); final officeCount = officeCountByUser[profile.id] ?? 0; final officeLabel = officeCount == 0 ? 'None' : '$officeCount'; final officeNames = assignments .where((assignment) => assignment.userId == profile.id) .map( (assignment) => officeNameById[assignment.officeId] ?? assignment.officeId, ) .toList(); final officesText = officeNames.isEmpty ? 'No offices' : officeNames.join(', '); final lastActive = _formatLastActive( lastActiveByUser[profile.id]?.toLocal(), ); return DataRow.byIndex( index: index, onSelectChanged: (selected) { if (selected != true) return; _showUserDialog(context, profile, offices, assignments); }, color: MaterialStateProperty.resolveWith((states) { if (states.contains(MaterialState.selected)) { return Theme.of( context, ).colorScheme.surfaceTint.withOpacity(0.12); } if (index.isEven) { return Theme.of( context, ).colorScheme.surface.withOpacity(0.6); } return Theme.of(context).colorScheme.surface; }), cells: [ DataCell(Text(label)), DataCell(Text(email)), DataCell(Text(profile.role)), DataCell( Tooltip(message: officesText, child: Text(officeLabel)), ), DataCell(Text(statusLabel)), DataCell(Text(lastActive)), ], ); }).toList(), ), ), ), ), ); } void _ensureStatusLoaded(String userId) { if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) { return; } _statusLoading.add(userId); _statusErrors.remove(userId); ref .read(adminUserControllerProvider) .fetchStatus(userId) .then((status) { if (!mounted) return; setState(() { _statusCache[userId] = status; _statusLoading.remove(userId); }); }) .catchError((_) { if (!mounted) return; setState(() { _statusLoading.remove(userId); _statusErrors.add(userId); }); }); } void _prefetchStatuses(List profiles) { final ids = profiles.map((profile) => profile.id).toSet(); final missing = ids.difference(_prefetchedUserIds); if (missing.isEmpty) return; _prefetchedUserIds = ids; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; for (final userId in missing) { _ensureStatusLoaded(userId); } }); } Future _showUserDialog( BuildContext context, Profile profile, List offices, List assignments, ) async { await _selectUser(profile); final currentOfficeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet(); if (!context.mounted) return; await showDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: const Text('Update user'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: SingleChildScrollView( child: _buildUserForm( context, profile, offices, currentOfficeIds, setDialogState, ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), ), ], ); }, ); }, ); } Widget _buildUserForm( BuildContext context, Profile profile, List offices, Set currentOfficeIds, StateSetter setDialogState, ) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _fullNameController, decoration: const InputDecoration(labelText: 'Full name'), ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('role_${_selectedUserId ?? 'none'}'), initialValue: _selectedRole, items: _roles .map((role) => DropdownMenuItem(value: role, child: Text(role))) .toList(), onChanged: (value) => setDialogState(() => _selectedRole = value), decoration: const InputDecoration(labelText: 'Role'), ), const SizedBox(height: 12), _buildStatusRow(profile), const SizedBox(height: 16), Text('Offices', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), if (offices.isEmpty) const Text('No offices available.'), if (offices.isNotEmpty) Column( children: offices .map( (office) => CheckboxListTile( value: _selectedOfficeIds.contains(office.id), onChanged: _isSaving ? null : (selected) { setDialogState(() { if (selected == true) { _selectedOfficeIds.add(office.id); } else { _selectedOfficeIds.remove(office.id); } }); }, title: Text(office.name), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ) .toList(), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton( onPressed: _isSaving ? null : () async { final saved = await _saveChanges( context, profile, currentOfficeIds, setDialogState, ); if (saved && context.mounted) { Navigator.of(context).pop(); } }, child: _isSaving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save changes'), ), ), ], ), ], ); } Widget _buildStatusRow(Profile profile) { final email = _selectedStatus?.email; final isLocked = _selectedStatus?.isLocked ?? false; final lockLabel = isLocked ? 'Unlock' : 'Lock'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Email: ${email ?? 'Loading...'}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), Row( children: [ OutlinedButton.icon( onPressed: _isStatusLoading ? null : () => _showPasswordResetDialog(profile.id), icon: const Icon(Icons.password), label: const Text('Reset password'), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: _isStatusLoading ? null : () => _toggleLock(profile.id, !isLocked), icon: Icon(isLocked ? Icons.lock_open : Icons.lock), label: Text(lockLabel), ), ], ), ], ); } Future _selectUser(Profile profile) async { setState(() { _selectedUserId = profile.id; _selectedRole = profile.role; _fullNameController.text = profile.fullName; _selectedStatus = null; _isStatusLoading = true; }); final assignments = ref.read(userOfficesProvider).valueOrNull ?? []; final officeIds = assignments .where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet(); setState(() => _selectedOfficeIds = officeIds); try { final status = await ref .read(adminUserControllerProvider) .fetchStatus(profile.id); if (mounted) { setState(() { _selectedStatus = status; _statusCache[profile.id] = status; }); } } catch (_) { if (mounted) { setState( () => _selectedStatus = AdminUserStatus(email: null, bannedUntil: null), ); } } finally { if (mounted) { setState(() => _isStatusLoading = false); } } } Future _saveChanges( BuildContext context, Profile profile, Set currentOfficeIds, StateSetter setDialogState, ) async { final role = _selectedRole ?? profile.role; final fullName = _fullNameController.text.trim(); if (fullName.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Full name is required.'))); return false; } if (_selectedOfficeIds.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Select at least one office.')), ); return false; } setDialogState(() => _isSaving = true); try { await ref .read(adminUserControllerProvider) .updateProfile(userId: profile.id, fullName: fullName, role: role); final toAdd = _selectedOfficeIds.difference(currentOfficeIds); final toRemove = currentOfficeIds.difference(_selectedOfficeIds); final controller = ref.read(userOfficesControllerProvider); for (final officeId in toAdd) { await controller.assignUserOffice( userId: profile.id, officeId: officeId, ); } for (final officeId in toRemove) { await controller.removeUserOffice( userId: profile.id, officeId: officeId, ); } ref.invalidate(profilesProvider); ref.invalidate(userOfficesProvider); if (!context.mounted) return true; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('User updated.'))); return true; } catch (error) { if (!context.mounted) return false; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Update failed: $error'))); return false; } finally { setDialogState(() => _isSaving = false); } } Future _showPasswordResetDialog(String userId) async { final controller = TextEditingController(); final formKey = GlobalKey(); await showDialog( context: context, builder: (dialogContext) { return AlertDialog( title: const Text('Set temporary password'), content: Form( key: formKey, child: TextFormField( controller: controller, decoration: const InputDecoration(labelText: 'New password'), obscureText: true, validator: (value) { if (value == null || value.trim().length < 8) { return 'Use at least 8 characters.'; } return null; }, ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () async { if (!formKey.currentState!.validate()) return; try { await ref .read(adminUserControllerProvider) .setPassword( userId: userId, password: controller.text.trim(), ); if (!dialogContext.mounted) return; Navigator.of(dialogContext).pop(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Password updated.')), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Reset failed: $error')), ); } }, child: const Text('Update password'), ), ], ); }, ); } Future _toggleLock(String userId, bool locked) async { setState(() => _isStatusLoading = true); try { await ref .read(adminUserControllerProvider) .setLock(userId: userId, locked: locked); final status = await ref .read(adminUserControllerProvider) .fetchStatus(userId); if (!mounted) return; setState(() => _selectedStatus = status); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')), ); } catch (error) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Lock update failed: $error'))); } finally { if (mounted) { setState(() => _isStatusLoading = false); } } } String _formatLastActive(DateTime? value) { if (value == null) return 'N/A'; final now = DateTime.now(); final diff = now.difference(value); if (diff.inMinutes < 1) return 'Just now'; if (diff.inHours < 1) return '${diff.inMinutes}m ago'; if (diff.inDays < 1) return '${diff.inHours}h ago'; if (diff.inDays < 7) return '${diff.inDays}d ago'; final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); return '${value.year}-$month-$day'; } }