Implementert admin-funksjoner: Dashboard, Ansattliste, Avvikshåndtering og Profil-oppdatering

This commit is contained in:
steinhelge
2025-11-24 21:17:29 +01:00
parent feda00ac83
commit 237d56066b
7 changed files with 578 additions and 45 deletions
+44
View File
@@ -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<List<UserModel>>((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<List<Deviation>, 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,
);
});
@@ -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),
],
),
),
),
);
}
}
@@ -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<DeviationListScreen> createState() => _DeviationListScreenState();
}
class _DeviationListScreenState extends ConsumerState<DeviationListScreen> {
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<void> _showAcknowledgeDialog(BuildContext context, Deviation deviation) async {
final commentController = TextEditingController();
final confirmed = await showDialog<bool>(
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)),
],
),
);
}
}
+73
View File
@@ -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;
}
}
}
+76 -45
View File
@@ -1,74 +1,94 @@
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 '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../models/user_model.dart';
import '../admin/admin_dashboard_screen.dart';
class ProfileScreen extends ConsumerWidget { class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final userData = ref.watch(userDataProvider); final userDataAsync = ref.watch(userDataProvider);
final signOut = ref.read(signOutProvider); final authService = ref.read(authServiceProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Profil'), title: const Text('Profil'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await authService.signOut();
},
),
],
), ),
body: userData.when( body: userDataAsync.when(
data: (user) { data: (user) {
if (user == null) { if (user == null) return const Center(child: Text('Ingen brukerdata'));
return const Center(child: Text('Ingen brukerdata'));
}
final isAdmin = user.role == UserRole.admin || user.role == UserRole.systemAdmin;
return ListView( return ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ children: [
Card( const CircleAvatar(
child: Padding( radius: 50,
padding: const EdgeInsets.all(16.0), child: Icon(Icons.person, size: 50),
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 SizedBox(height: 16), 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( ListTile(
leading: const Icon(Icons.business), leading: const Icon(Icons.business),
title: const Text('Organisasjon'), title: const Text('Organisasjon'),
subtitle: Text(user.organizationId), 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(), const Divider(),
@@ -76,7 +96,7 @@ class ProfileScreen extends ConsumerWidget {
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: const Text('Logg ut'), title: const Text('Logg ut'),
onTap: () async { 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';
}
}
} }
+14
View File
@@ -140,6 +140,20 @@ class AuthService {
return 'default_aml'; return 'default_aml';
} }
// Hent alle brukere i en organisasjon
Future<List<UserModel>> 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 // Håndter Firebase Auth exceptions
String _handleAuthException(FirebaseAuthException e) { String _handleAuthException(FirebaseAuthException e) {
switch (e.code) { switch (e.code) {
+38
View File
@@ -1,6 +1,7 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/time_registration.dart'; import '../models/time_registration.dart';
import '../models/deviation.dart';
class TimeService { class TimeService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance;
@@ -319,4 +320,41 @@ class TimeService {
'overtime': overtimeMinutes, 'overtime': overtimeMinutes,
}; };
} }
// Hent avvik for organisasjon
Future<List<Deviation>> 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<void> 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');
}
}
} }