674 lines
21 KiB
Dart
674 lines
21 KiB
Dart
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<UserManagementScreen> createState() =>
|
|
_UserManagementScreenState();
|
|
}
|
|
|
|
class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|
static const List<String> _roles = [
|
|
'standard',
|
|
'dispatcher',
|
|
'it_staff',
|
|
'admin',
|
|
];
|
|
|
|
final _fullNameController = TextEditingController();
|
|
|
|
String? _selectedUserId;
|
|
String? _selectedRole;
|
|
Set<String> _selectedOfficeIds = {};
|
|
AdminUserStatus? _selectedStatus;
|
|
bool _isSaving = false;
|
|
bool _isStatusLoading = false;
|
|
final Map<String, AdminUserStatus> _statusCache = {};
|
|
final Set<String> _statusLoading = {};
|
|
final Set<String> _statusErrors = {};
|
|
Set<String> _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<List<Profile>> profilesAsync,
|
|
AsyncValue<List<Office>> officesAsync,
|
|
AsyncValue<List<UserOffice>> assignmentsAsync,
|
|
AsyncValue<List<TicketMessage>> 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 = <String, DateTime>{};
|
|
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<Profile> profiles,
|
|
List<Office> offices,
|
|
List<UserOffice> assignments,
|
|
Map<String, DateTime> 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 = <String, int>{};
|
|
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: WidgetStateProperty.resolveWith(
|
|
(states) =>
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
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: WidgetStateProperty.resolveWith((states) {
|
|
if (states.contains(WidgetState.selected)) {
|
|
return Theme.of(
|
|
context,
|
|
).colorScheme.surfaceTint.withValues(alpha: 0.12);
|
|
}
|
|
if (index.isEven) {
|
|
return Theme.of(
|
|
context,
|
|
).colorScheme.surface.withValues(alpha: 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<Profile> 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<void> _showUserDialog(
|
|
BuildContext context,
|
|
Profile profile,
|
|
List<Office> offices,
|
|
List<UserOffice> 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<void>(
|
|
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<Office> offices,
|
|
Set<String> 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<String>(
|
|
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<void> _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<bool> _saveChanges(
|
|
BuildContext context,
|
|
Profile profile,
|
|
Set<String> 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<void> _showPasswordResetDialog(String userId) async {
|
|
final controller = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
await showDialog<void>(
|
|
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<void> _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';
|
|
}
|
|
}
|