From 237d56066b2abeed56241d36c18210f6fb3d01e7 Mon Sep 17 00:00:00 2001 From: steinhelge Date: Mon, 24 Nov 2025 21:17:29 +0100 Subject: [PATCH] =?UTF-8?q?Implementert=20admin-funksjoner:=20Dashboard,?= =?UTF-8?q?=20Ansattliste,=20Avviksh=C3=A5ndtering=20og=20Profil-oppdateri?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/admin_provider.dart | 44 ++++ lib/screens/admin/admin_dashboard_screen.dart | 104 ++++++++ lib/screens/admin/deviation_list_screen.dart | 229 ++++++++++++++++++ lib/screens/admin/user_list_screen.dart | 73 ++++++ lib/screens/profile/profile_screen.dart | 121 +++++---- lib/services/auth_service.dart | 14 ++ lib/services/time_service.dart | 38 +++ 7 files changed, 578 insertions(+), 45 deletions(-) create mode 100644 lib/providers/admin_provider.dart create mode 100644 lib/screens/admin/admin_dashboard_screen.dart create mode 100644 lib/screens/admin/deviation_list_screen.dart create mode 100644 lib/screens/admin/user_list_screen.dart diff --git a/lib/providers/admin_provider.dart b/lib/providers/admin_provider.dart new file mode 100644 index 0000000..92dd6d7 --- /dev/null +++ b/lib/providers/admin_provider.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/user_model.dart'; +import '../models/deviation.dart'; +import 'auth_provider.dart'; +import 'time_provider.dart'; + +// Provider for organisasjonens brukere +final organizationUsersProvider = FutureProvider>((ref) async { + final user = ref.watch(currentUserProvider); + final userData = await ref.watch(userDataProvider.future); + + if (user == null || userData == null) { + return []; + } + + // Sjekk om bruker er admin + if (userData.role != UserRole.admin && userData.role != UserRole.systemAdmin) { + return []; + } + + final authService = ref.read(authServiceProvider); + return authService.getUsersInOrganization(userData.organizationId); +}); + +// Provider for organisasjonens avvik +final organizationDeviationsProvider = FutureProvider.family, bool?>((ref, onlyUnacknowledged) async { + final user = ref.watch(currentUserProvider); + final userData = await ref.watch(userDataProvider.future); + + if (user == null || userData == null) { + return []; + } + + // Sjekk om bruker er admin + if (userData.role != UserRole.admin && userData.role != UserRole.systemAdmin) { + return []; + } + + final timeService = ref.read(timeServiceProvider); + return timeService.getDeviations( + organizationId: userData.organizationId, + onlyUnacknowledged: onlyUnacknowledged, + ); +}); diff --git a/lib/screens/admin/admin_dashboard_screen.dart b/lib/screens/admin/admin_dashboard_screen.dart new file mode 100644 index 0000000..e3625f9 --- /dev/null +++ b/lib/screens/admin/admin_dashboard_screen.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'user_list_screen.dart'; +import 'deviation_list_screen.dart'; + +class AdminDashboardScreen extends ConsumerWidget { + const AdminDashboardScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('Admin Dashboard'), + ), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _AdminCard( + title: 'Ansatte', + icon: Icons.people, + color: Colors.blue, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const UserListScreen()), + ); + }, + ), + const SizedBox(height: 16), + _AdminCard( + title: 'Avvik', + icon: Icons.warning_amber, + color: Colors.orange, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DeviationListScreen()), + ); + }, + ), + const SizedBox(height: 16), + _AdminCard( + title: 'Rapporter', + icon: Icons.bar_chart, + color: Colors.green, + onTap: () { + // TODO: Implementer admin-rapporter + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Kommer snart')), + ); + }, + ), + ], + ), + ); + } +} + +class _AdminCard extends StatelessWidget { + final String title; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _AdminCard({ + required this.title, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 32, color: color), + ), + const SizedBox(width: 24), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/admin/deviation_list_screen.dart b/lib/screens/admin/deviation_list_screen.dart new file mode 100644 index 0000000..b03d791 --- /dev/null +++ b/lib/screens/admin/deviation_list_screen.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../providers/admin_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/time_provider.dart'; +import '../../models/deviation.dart'; + +class DeviationListScreen extends ConsumerStatefulWidget { + const DeviationListScreen({super.key}); + + @override + ConsumerState createState() => _DeviationListScreenState(); +} + +class _DeviationListScreenState extends ConsumerState { + bool _onlyUnacknowledged = true; + + @override + Widget build(BuildContext context) { + final deviationsAsync = ref.watch(organizationDeviationsProvider(_onlyUnacknowledged)); + + return Scaffold( + appBar: AppBar( + title: const Text('Avvikshåndtering'), + actions: [ + IconButton( + icon: Icon(_onlyUnacknowledged ? Icons.filter_alt : Icons.filter_alt_off), + onPressed: () { + setState(() { + _onlyUnacknowledged = !_onlyUnacknowledged; + }); + }, + tooltip: _onlyUnacknowledged ? 'Vis alle' : 'Vis kun åpne', + ), + ], + ), + body: deviationsAsync.when( + data: (deviations) { + if (deviations.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline, size: 64, color: Colors.green), + const SizedBox(height: 16), + Text( + _onlyUnacknowledged ? 'Ingen åpne avvik!' : 'Ingen avvik funnet', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } + return ListView.builder( + itemCount: deviations.length, + itemBuilder: (context, index) { + final deviation = deviations[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ExpansionTile( + leading: Icon( + Icons.warning, + color: _getSeverityColor(deviation.severity), + ), + title: Text( + deviation.description, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + DateFormat('dd.MM.yyyy HH:mm').format(deviation.detectedAt), + ), + trailing: deviation.acknowledgedAt != null + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailRow(label: 'Type', value: _getTypeDisplayName(deviation.type)), + if (deviation.metadata.actualValue != null) + _DetailRow( + label: 'Faktisk verdi', + value: '${deviation.metadata.actualValue}', + ), + if (deviation.metadata.limitValue != null) + _DetailRow( + label: 'Grenseverdi', + value: '${deviation.metadata.limitValue}', + ), + const SizedBox(height: 16), + if (deviation.acknowledgedAt == null) + FilledButton( + onPressed: () => _showAcknowledgeDialog(context, deviation), + child: const Text('Kvitter ut avvik'), + ) + else + Text( + 'Kvittert ut av ${deviation.acknowledgedBy ?? "ukjent"} den ${DateFormat('dd.MM.yyyy HH:mm').format(deviation.acknowledgedAt!)}', + style: const TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Feil: $error')), + ), + ); + } + + Future _showAcknowledgeDialog(BuildContext context, Deviation deviation) async { + final commentController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Kvitter ut avvik'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Er du sikker på at du vil kvittere ut dette avviket?'), + const SizedBox(height: 16), + TextField( + controller: commentController, + decoration: const InputDecoration( + labelText: 'Kommentar (valgfritt)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Kvitter ut'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final user = ref.read(currentUserProvider); + if (user == null) return; + + final timeService = ref.read(timeServiceProvider); + await timeService.acknowledgeDeviation( + deviationId: deviation.id, + userId: user.uid, + comment: commentController.text.isNotEmpty ? commentController.text : null, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Avvik kvittert ut')), + ); + // Refresh list + ref.invalidate(organizationDeviationsProvider); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Feil: $e')), + ); + } + } + } + } + + Color _getSeverityColor(DeviationSeverity severity) { + switch (severity) { + case DeviationSeverity.warning: + return Colors.orange; + case DeviationSeverity.violation: + return Colors.red; + } + } + + String _getTypeDisplayName(DeviationType type) { + switch (type) { + case DeviationType.dailyMax: + return 'Daglig maksgrense'; + case DeviationType.weeklyMax: + return 'Ukentlig maksgrense'; + case DeviationType.dailyRest: + return 'Daglig hviletid'; + case DeviationType.weeklyRest: + return 'Ukentlig hviletid'; + case DeviationType.averageExceeded: + return 'Gjennomsnittsberegning overskredet'; + case DeviationType.nightWork: + return 'Nattarbeid'; + } + } +} + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.grey)), + Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ); + } +} diff --git a/lib/screens/admin/user_list_screen.dart b/lib/screens/admin/user_list_screen.dart new file mode 100644 index 0000000..950549c --- /dev/null +++ b/lib/screens/admin/user_list_screen.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/admin_provider.dart'; +import '../../models/user_model.dart'; + +class UserListScreen extends ConsumerWidget { + const UserListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usersAsync = ref.watch(organizationUsersProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Ansatte'), + ), + body: usersAsync.when( + data: (users) { + if (users.isEmpty) { + return const Center(child: Text('Ingen ansatte funnet')); + } + return ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return ListTile( + leading: CircleAvatar( + child: Text(user.displayName.isNotEmpty ? user.displayName[0].toUpperCase() : '?'), + ), + title: Text(user.displayName), + subtitle: Text(user.email), + trailing: Chip( + label: Text(_getRoleDisplayName(user.role)), + backgroundColor: _getRoleColor(user.role).withOpacity(0.2), + ), + onTap: () { + // TODO: Naviger til brukerdetaljer/timeliste + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Detaljvisning kommer snart')), + ); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Feil: $error')), + ), + ); + } + + String _getRoleDisplayName(UserRole role) { + switch (role) { + case UserRole.employee: + return 'Ansatt'; + case UserRole.admin: + return 'Admin'; + case UserRole.systemAdmin: + return 'System Admin'; + } + } + + Color _getRoleColor(UserRole role) { + switch (role) { + case UserRole.employee: + return Colors.blue; + case UserRole.admin: + return Colors.orange; + case UserRole.systemAdmin: + return Colors.red; + } + } +} diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index ca2870b..1682bbe 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -1,74 +1,94 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/auth_provider.dart'; +import '../../models/user_model.dart'; +import '../admin/admin_dashboard_screen.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final userData = ref.watch(userDataProvider); - final signOut = ref.read(signOutProvider); + final userDataAsync = ref.watch(userDataProvider); + final authService = ref.read(authServiceProvider); return Scaffold( appBar: AppBar( title: const Text('Profil'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await authService.signOut(); + }, + ), + ], ), - body: userData.when( + body: userDataAsync.when( data: (user) { - if (user == null) { - return const Center(child: Text('Ingen brukerdata')); - } + if (user == null) return const Center(child: Text('Ingen brukerdata')); + final isAdmin = user.role == UserRole.admin || user.role == UserRole.systemAdmin; + return ListView( padding: const EdgeInsets.all(16.0), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - CircleAvatar( - radius: 40, - child: Text( - user.displayName.isNotEmpty - ? user.displayName[0].toUpperCase() - : '?', - style: const TextStyle(fontSize: 32), - ), - ), - const SizedBox(height: 16), - Text( - user.displayName, - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - user.email, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - Chip( - label: Text( - user.role.name == 'employee' - ? 'Ansatt' - : user.role.name == 'admin' - ? 'Administrator' - : 'Systemadministrator', - ), - ), - ], - ), - ), + const CircleAvatar( + radius: 50, + child: Icon(Icons.person, size: 50), ), const SizedBox(height: 16), + Text( + user.displayName, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + user.email, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 32), + if (isAdmin) ...[ + Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: ListTile( + leading: const Icon(Icons.admin_panel_settings), + title: const Text('Admin Dashboard'), + subtitle: const Text('Administrer ansatte og avvik'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AdminDashboardScreen(), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + + const Divider(), ListTile( leading: const Icon(Icons.business), title: const Text('Organisasjon'), subtitle: Text(user.organizationId), ), + ListTile( + leading: const Icon(Icons.badge), + title: const Text('Rolle'), + subtitle: Text(_getRoleDisplayName(user.role)), + ), + ListTile( + leading: const Icon(Icons.rule), + title: const Text('Tariffprofil'), + subtitle: Text(user.tariffProfileId), + ), const Divider(), @@ -76,7 +96,7 @@ class ProfileScreen extends ConsumerWidget { leading: const Icon(Icons.logout), title: const Text('Logg ut'), onTap: () async { - await signOut(); + await authService.signOut(); }, ), ], @@ -87,4 +107,15 @@ class ProfileScreen extends ConsumerWidget { ), ); } + + String _getRoleDisplayName(UserRole role) { + switch (role) { + case UserRole.employee: + return 'Ansatt'; + case UserRole.admin: + return 'Admin'; + case UserRole.systemAdmin: + return 'System Admin'; + } + } } diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 07ed9cc..ac5c552 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -140,6 +140,20 @@ class AuthService { return 'default_aml'; } + // Hent alle brukere i en organisasjon + Future> getUsersInOrganization(String organizationId) async { + try { + final snapshot = await _firestore + .collection('users') + .where('organizationId', isEqualTo: organizationId) + .get(); + + return snapshot.docs.map((doc) => UserModel.fromFirestore(doc)).toList(); + } catch (e) { + throw Exception('Kunne ikke hente brukere: $e'); + } + } + // Håndter Firebase Auth exceptions String _handleAuthException(FirebaseAuthException e) { switch (e.code) { diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index ac116e6..0f05f40 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -1,6 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:uuid/uuid.dart'; import '../models/time_registration.dart'; +import '../models/deviation.dart'; class TimeService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; @@ -319,4 +320,41 @@ class TimeService { 'overtime': overtimeMinutes, }; } + // Hent avvik for organisasjon + Future> getDeviations({ + required String organizationId, + bool? onlyUnacknowledged, + }) async { + try { + Query query = _firestore + .collection('deviations') + .where('organizationId', isEqualTo: organizationId); + + if (onlyUnacknowledged == true) { + query = query.where('acknowledgedAt', isNull: true); + } + + final snapshot = await query.orderBy('detectedAt', descending: true).get(); + return snapshot.docs.map((doc) => Deviation.fromFirestore(doc)).toList(); + } catch (e) { + throw Exception('Kunne ikke hente avvik: $e'); + } + } + + // Kvitter ut avvik + Future acknowledgeDeviation({ + required String deviationId, + required String userId, + String? comment, + }) async { + try { + await _firestore.collection('deviations').doc(deviationId).update({ + 'acknowledgedAt': Timestamp.now(), + 'acknowledgedBy': userId, + if (comment != null) 'comment': comment, + }); + } catch (e) { + throw Exception('Kunne ikke kvittere ut avvik: $e'); + } + } }