diff --git a/lib/models/team.dart b/lib/models/team.dart new file mode 100644 index 00000000..c79aac57 --- /dev/null +++ b/lib/models/team.dart @@ -0,0 +1,39 @@ +// Extension to add members property to Team +import '../models/team_member.dart'; + +extension TeamMembersExtension on Team { + List members(List allMembers) { + return allMembers + .where((m) => m.teamId == id) + .map((m) => m.userId) + .toList(); + } +} + +class Team { + Team({ + required this.id, + required this.name, + required this.leaderId, + required this.officeIds, + required this.createdAt, + }); + + final String id; + final String name; + final String leaderId; + final List officeIds; + final DateTime createdAt; + + factory Team.fromMap(Map map) { + return Team( + id: map['id'] as String, + name: map['name'] as String? ?? '', + leaderId: map['leader_id'] as String? ?? '', + officeIds: + (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? + [], + createdAt: DateTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/models/team_member.dart b/lib/models/team_member.dart new file mode 100644 index 00000000..d2b56f35 --- /dev/null +++ b/lib/models/team_member.dart @@ -0,0 +1,13 @@ +class TeamMember { + TeamMember({required this.teamId, required this.userId}); + + final String teamId; + final String userId; + + factory TeamMember.fromMap(Map map) { + return TeamMember( + teamId: map['team_id'] as String, + userId: map['user_id'] as String, + ); + } +} diff --git a/lib/providers/teams_provider.dart b/lib/providers/teams_provider.dart new file mode 100644 index 00000000..76817933 --- /dev/null +++ b/lib/providers/teams_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/team.dart'; +import '../models/team_member.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +final teamsProvider = FutureProvider>((ref) async { + final data = await Supabase.instance.client + .from('teams') + .select() + .order('name'); + return (data as List? ?? []) + .map((e) => Team.fromMap(e as Map)) + .toList(); +}); + +final teamMembersProvider = FutureProvider>((ref) async { + final data = await Supabase.instance.client.from('team_members').select(); + return (data as List? ?? []) + .map((e) => TeamMember.fromMap(e as Map)) + .toList(); +}); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 89b655d6..79d27a68 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -17,6 +17,7 @@ import '../screens/tickets/ticket_detail_screen.dart'; import '../screens/tickets/tickets_list_screen.dart'; import '../screens/workforce/workforce_screen.dart'; import '../widgets/app_shell.dart'; +import '../screens/teams/teams_screen.dart'; final appRouterProvider = Provider((ref) { final notifier = RouterNotifier(ref); @@ -61,6 +62,10 @@ final appRouterProvider = Provider((ref) { ShellRoute( builder: (context, state, child) => AppScaffold(child: child), routes: [ + GoRoute( + path: '/settings/teams', + builder: (context, state) => const TeamsScreen(), + ), GoRoute( path: '/dashboard', builder: (context, state) => const DashboardScreen(), diff --git a/lib/screens/searchable_multi_select_dropdown.dart b/lib/screens/searchable_multi_select_dropdown.dart new file mode 100644 index 00000000..f80a4bf3 --- /dev/null +++ b/lib/screens/searchable_multi_select_dropdown.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +/// Searchable multi-select dropdown with chips and 'Select All' option +class SearchableMultiSelectDropdown extends StatefulWidget { + const SearchableMultiSelectDropdown({ + Key? key, + required this.label, + required this.items, + required this.selectedIds, + required this.getId, + required this.getLabel, + required this.onChanged, + }) : super(key: key); + + final String label; + final List items; + final List selectedIds; + final String Function(T) getId; + final String Function(T) getLabel; + final ValueChanged> onChanged; + + @override + State> createState() => + SearchableMultiSelectDropdownState(); +} + +class SearchableMultiSelectDropdownState + extends State> { + late List _selectedIds; + String _search = ''; + bool _selectAll = false; + + @override + void initState() { + super.initState(); + _selectedIds = List.from(widget.selectedIds); + _selectAll = + _selectedIds.length == widget.items.length && widget.items.isNotEmpty; + } + + void _openDropdown() async { + await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + final filtered = widget.items + .where( + (item) => widget + .getLabel(item) + .toLowerCase() + .contains(_search.toLowerCase()), + ) + .toList(); + return AlertDialog( + title: Text('Select ${widget.label}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration(hintText: 'Search...'), + onChanged: (v) => setState(() => _search = v), + ), + CheckboxListTile( + value: _selectAll, + title: const Text('Select All'), + onChanged: (checked) { + setState(() { + _selectAll = checked ?? false; + if (_selectAll) { + _selectedIds = widget.items + .map(widget.getId) + .toList(); + } else { + _selectedIds.clear(); + } + }); + }, + ), + SizedBox( + height: 200, + child: ListView( + children: [ + for (final item in filtered) + CheckboxListTile( + value: _selectedIds.contains(widget.getId(item)), + title: Text(widget.getLabel(item)), + onChanged: (checked) { + setState(() { + final id = widget.getId(item); + if (checked == true) { + _selectedIds.add(id); + } else { + _selectedIds.remove(id); + } + _selectAll = + _selectedIds.length == + widget.items.length && + widget.items.isNotEmpty; + }); + }, + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(_selectedIds), + child: const Text('Done'), + ), + ], + ); + }, + ); + }, + ).then((result) { + if (result is List) { + setState(() { + _selectedIds = result; + _selectAll = + _selectedIds.length == widget.items.length && + widget.items.isNotEmpty; + }); + widget.onChanged(_selectedIds); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InputDecorator( + decoration: InputDecoration(labelText: widget.label), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + ..._selectedIds.map((id) { + T item; + try { + item = widget.items.firstWhere((e) => widget.getId(e) == id); + } catch (_) { + return const SizedBox.shrink(); + } + return Chip( + label: Text(widget.getLabel(item)), + onDeleted: () { + setState(() { + _selectedIds.remove(id); + _selectAll = + _selectedIds.length == widget.items.length && + widget.items.isNotEmpty; + }); + widget.onChanged(_selectedIds); + }, + ); + }), + ActionChip( + label: Text('Select'), + avatar: const Icon(Icons.arrow_drop_down), + onPressed: _openDropdown, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart new file mode 100644 index 00000000..bdfde295 --- /dev/null +++ b/lib/screens/teams/teams_screen.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/office.dart'; +import '../../models/profile.dart'; +import '../../models/team.dart'; +import '../../providers/teams_provider.dart'; +import '../../providers/profile_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:tasq/screens/searchable_multi_select_dropdown.dart'; +import '../../widgets/tasq_adaptive_list.dart'; + +final officesProvider = FutureProvider>((ref) async { + final data = await Supabase.instance.client.from('offices').select(); + return (data as List? ?? []) + .map((e) => Office.fromMap(e as Map)) + .toList(); +}); + +class TeamsScreen extends ConsumerWidget { + const TeamsScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final teamsAsync = ref.watch(teamsProvider); + final profilesAsync = ref.watch(profilesProvider); + final officesAsync = ref.watch(officesProvider); + final teamMembersAsync = ref.watch(teamMembersProvider); + + return Scaffold( + body: teamsAsync.when( + data: (teams) { + final profiles = profilesAsync.valueOrNull ?? []; + final offices = officesAsync.valueOrNull ?? []; + final teamMembers = teamMembersAsync.valueOrNull ?? []; + final profileById = {for (var p in profiles) p.id: p}; + final officeById = {for (var o in offices) o.id: o}; + + return TasQAdaptiveList( + items: teams, + columns: [ + TasQColumn( + header: 'Name', + cellBuilder: (context, team) => Text(team.name), + ), + TasQColumn( + header: 'Leader', + cellBuilder: (context, team) { + final leader = profileById[team.leaderId]; + return Text(leader?.fullName ?? team.leaderId); + }, + ), + TasQColumn( + header: 'Offices', + cellBuilder: (context, team) { + final officeNames = team.officeIds + .map((id) => officeById[id]?.name ?? id) + .join(', '); + return Text(officeNames); + }, + ), + TasQColumn( + header: 'Members', + cellBuilder: (context, team) { + final members = team.members(teamMembers); + final memberNames = members + .map((id) => profileById[id]?.fullName ?? id) + .join(', '); + return Text(memberNames); + }, + ), + ], + mobileTileBuilder: (context, team, actions) { + final leader = profileById[team.leaderId]; + final officeNames = team.officeIds + .map((id) => officeById[id]?.name ?? id) + .join(', '); + final members = team.members(teamMembers); + final memberNames = members + .map((id) => profileById[id]?.fullName ?? id) + .join(', '); + return ListTile( + title: Text(team.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Leader: ${leader?.fullName ?? team.leaderId}'), + Text('Offices: $officeNames'), + Text('Members: $memberNames'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: actions, + ), + onTap: () => _showTeamDialog(context, ref, team: team), + ); + }, + rowActions: (team) => [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Edit', + onPressed: () => _showTeamDialog(context, ref, team: team), + ), + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Delete', + onPressed: () => _deleteTeam(context, ref, team.id), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showTeamDialog(context, ref), + tooltip: 'Add Team', + child: const Icon(Icons.add), + ), + ); + } + + void _showTeamDialog( + BuildContext context, + WidgetRef ref, { + Team? team, + }) async { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final offices = await ref.read(officesProvider.future); + final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); + final nameController = TextEditingController(text: team?.name ?? ''); + String? leaderId = team?.leaderId; + List selectedOffices = List.from(team?.officeIds ?? []); + List selectedMembers = []; + final isEdit = team != null; + + // If editing, fetch current members + if (isEdit) { + final members = ref.read(teamMembersProvider).valueOrNull ?? []; + selectedMembers = members + .where((m) => m.teamId == team.id) + .map((m) => m.userId) + .toList(); + } + + await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text(isEdit ? 'Edit Team' : 'Add Team'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Team Name'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: leaderId, + items: [ + for (final p in itStaff) + DropdownMenuItem( + value: p.id, + child: Text( + p.fullName.isNotEmpty ? p.fullName : p.id, + ), + ), + ], + onChanged: (v) => setState(() => leaderId = v), + decoration: const InputDecoration( + labelText: 'Team Leader', + ), + ), + const SizedBox(height: 12), + SearchableMultiSelectDropdown( + label: 'Offices', + items: offices, + selectedIds: selectedOffices, + getId: (o) => o.id, + getLabel: (o) => o.name, + onChanged: (ids) => setState(() => selectedOffices = ids), + ), + const SizedBox(height: 12), + SearchableMultiSelectDropdown( + label: 'Team Members', + items: itStaff, + selectedIds: selectedMembers, + getId: (p) => p.id, + getLabel: (p) => + p.fullName.isNotEmpty ? p.fullName : p.id, + onChanged: (ids) => setState(() => selectedMembers = ids), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final name = nameController.text.trim(); + if (name.isEmpty || leaderId == null) return; + final client = Supabase.instance.client; + if (isEdit) { + await client + .from('teams') + .update({ + 'name': name, + 'leader_id': leaderId, + 'office_ids': selectedOffices, + }) + .eq('id', team.id); + // Update team members + await client + .from('team_members') + .delete() + .eq('team_id', team.id); + if (selectedMembers.isNotEmpty) { + await client.from('team_members').insert([ + for (final userId in selectedMembers) + {'team_id': team.id, 'user_id': userId}, + ]); + } + } else { + final insertRes = await client + .from('teams') + .insert({ + 'name': name, + 'leader_id': leaderId, + 'office_ids': selectedOffices, + }) + .select() + .single(); + final newTeamId = insertRes['id'] as String?; + if (newTeamId != null && selectedMembers.isNotEmpty) { + await client.from('team_members').insert([ + for (final userId in selectedMembers) + {'team_id': newTeamId, 'user_id': userId}, + ]); + } + } + ref.invalidate(teamsProvider); + ref.invalidate(teamMembersProvider); + Navigator.of(context).pop(); + }, + child: Text(isEdit ? 'Save' : 'Add'), + ), + ], + ); + }, + ); + }, + ); + } + + void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Team'), + content: const Text('Are you sure you want to delete this team?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true) { + await Supabase.instance.client.from('teams').delete().eq('id', teamId); + ref.invalidate(teamsProvider); + } + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index b89f54bb..dd882d09 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -344,7 +344,7 @@ List _buildSections(String role) { ), ]; - if (role == 'admin') { + if (role == 'admin' || role == 'dispatcher') { return [ NavSection(label: 'Operations', items: mainItems), NavSection( @@ -362,6 +362,12 @@ List _buildSections(String role) { icon: Icons.apartment_outlined, selectedIcon: Icons.apartment, ), + NavItem( + label: 'IT Staff Teams', + route: '/settings/teams', + icon: Icons.groups_2_outlined, + selectedIcon: Icons.groups_2, + ), NavItem( label: 'Logout', route: '',