import 'package:flutter/material.dart'; import '../../theme/m3_motion.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 '../../providers/tickets_provider.dart'; import '../../providers/supabase_provider.dart'; import '../../utils/supabase_response.dart'; import 'package:tasq/widgets/multi_select_picker.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../utils/snackbar.dart'; // Note: `officesProvider` is provided globally in `tickets_provider.dart` so // we reuse that StreamProvider here (avoids duplicate queries). class TeamsScreen extends ConsumerStatefulWidget { const TeamsScreen({super.key}); @override ConsumerState createState() => _TeamsScreenState(); } class _TeamsScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); String? _selectedOfficeId; String? _selectedLeaderId; @override void dispose() { _searchController.dispose(); super.dispose(); } bool get _hasFilters { return _searchController.text.trim().isNotEmpty || _selectedOfficeId != null || _selectedLeaderId != null; } @override Widget build(BuildContext context) { 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}; // filters final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), ...offices.map( (o) => DropdownMenuItem(value: o.id, child: Text(o.name)), ), ]; final leaderOptions = >[ const DropdownMenuItem( value: null, child: Text('All leaders'), ), ...itStaff.map( (p) => DropdownMenuItem( value: p.id, child: Text(p.fullName.isNotEmpty ? p.fullName : p.id), ), ), ]; final filteredTeams = teams.where((t) { final matchesName = _searchController.text.trim().isEmpty || t.name.toLowerCase().contains( _searchController.text.trim().toLowerCase(), ); final matchesOffice = _selectedOfficeId == null || t.officeIds.contains(_selectedOfficeId); final matchesLeader = _selectedLeaderId == null || t.leaderId == _selectedLeaderId; return matchesName && matchesOffice && matchesLeader; }).toList(); final filterHeader = Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ SizedBox( width: 280, child: TextField( controller: _searchController, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Search team name', prefixIcon: Icon(Icons.search), ), ), ), SizedBox( width: 200, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedOfficeId), initialValue: _selectedOfficeId, items: officeOptions, onChanged: (v) => setState(() => _selectedOfficeId = v), decoration: const InputDecoration(labelText: 'Office'), ), ), SizedBox( width: 220, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedLeaderId), initialValue: _selectedLeaderId, items: leaderOptions, onChanged: (v) => setState(() => _selectedLeaderId = v), decoration: const InputDecoration(labelText: 'Team leader'), ), ), if (_hasFilters) TextButton.icon( onPressed: () => setState(() { _searchController.clear(); _selectedOfficeId = null; _selectedLeaderId = null; }), icon: const Icon(Icons.close), label: const Text('Clear'), ), ], ); final listBody = TasQAdaptiveList( items: filteredTeams, filterHeader: filterHeader, 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 officeNamesList = team.officeIds .map((id) => officeById[id]?.name ?? id) .toList(); officeNamesList.sort( (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), ); final officeNames = officeNamesList.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 officeNamesList = team.officeIds .map((id) => officeById[id]?.name ?? id) .toList(); officeNamesList.sort( (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), ); final officeNames = officeNamesList.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, team: team), ); }, rowActions: (team) => [ IconButton( icon: const Icon(Icons.edit), tooltip: 'Edit', onPressed: () => _showTeamDialog(context, team: team), ), IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete', onPressed: () => _deleteTeam(context, team.id), ), ], ); return Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Stack( alignment: Alignment.center, children: [ Align( alignment: Alignment.center, child: Text( 'Team Management', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ], ), ), Expanded(child: listBody), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Error: $err')), ), floatingActionButton: M3Fab( onPressed: () => _showTeamDialog(context), tooltip: 'Add Team', icon: const Icon(Icons.add), ), ); } void _showTeamDialog(BuildContext context, {Team? team}) async { final profiles = ref.read(profilesProvider).valueOrNull ?? []; // officesProvider is a StreamProvider (global) — try to read the latest // synchronous value first, otherwise await the first stream event. final officesSync = ref.read(officesProvider).valueOrNull; final List offices = officesSync ?? await ref.read(officesProvider.future); if (!context.mounted) return; final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final nameController = TextEditingController(text: team?.name ?? ''); String? leaderId = team?.leaderId; Color? teamColor = team?.color != null ? Color(int.parse(team!.color!, radix: 16) | 0xFF000000) : null; 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(); } final bool isMobileDialog = MediaQuery.of(context).size.width <= 600; const double kDialogMaxWidth = 720; // Dialog content builder (captures outer local vars like leaderId, // selectedOffices, selectedMembers, nameController). Widget buildFormContent(StateSetter setState) { return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: const InputDecoration(labelText: 'Team Name'), ), const SizedBox(height: 12), _TeamColorPicker( selectedColor: teamColor, onColorChanged: (c) => setState(() => teamColor = c), ), 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), // Available offices: exclude offices already assigned to other teams Builder( builder: (context) { final allTeams = ref.read(teamsProvider).valueOrNull ?? []; final assignedOfficeIds = allTeams .where((t) => t.id != team?.id) .expand((t) => t.officeIds) .toSet(); final availableOffices = offices .where( (o) => !assignedOfficeIds.contains(o.id) || selectedOffices.contains(o.id), ) .toList(); return MultiSelectPicker( label: 'Offices', items: availableOffices, selectedIds: selectedOffices, getId: (o) => o.id, getLabel: (o) => o.name, onChanged: (ids) => setState(() => selectedOffices = ids), ); }, ), const SizedBox(height: 12), Builder( builder: (context) { final members = ref.read(teamMembersProvider).valueOrNull ?? []; final assignedUserIds = members .where((m) => m.teamId != team?.id) .map((m) => m.userId) .toSet(); final availableItStaff = itStaff .where( (p) => !assignedUserIds.contains(p.id) || selectedMembers.contains(p.id), ) .toList(); return MultiSelectPicker( label: 'Team Members', items: availableItStaff, selectedIds: selectedMembers, getId: (p) => p.id, getLabel: (p) => p.fullName.isNotEmpty ? p.fullName : p.id, onChanged: (ids) => setState(() => selectedMembers = ids), ); }, ), ], ), ); } Future onSave(StateSetter setState, NavigatorState navigator) async { final ctx = context; // capture for async use final name = nameController.text.trim(); if (name.isEmpty) { showWarningSnackBar(ctx, 'Team name is required'); return; } if (leaderId == null) { showWarningSnackBar(ctx, 'Please select a team leader'); return; } if (selectedOffices.isEmpty) { showWarningSnackBar(ctx, 'Assign at least one office to the team'); return; } // ensure leader included if (leaderId != null && !selectedMembers.contains(leaderId)) { selectedMembers = [leaderId!, ...selectedMembers]; } final client = ref.read(supabaseClientProvider); try { if (isEdit) { // update team row final upRes = await client .from('teams') .update({ 'name': name, 'leader_id': leaderId, 'office_ids': selectedOffices, 'color': teamColor != null ? (teamColor!.toARGB32() & 0xFFFFFF) .toRadixString(16) .padLeft(6, '0') : null, }) .eq('id', team.id); final upErr = extractSupabaseError(upRes); if (upErr != null) throw Exception(upErr); // replace members for the team final delRes = await client .from('team_members') .delete() .eq('team_id', team.id); final delErr = extractSupabaseError(delRes); if (delErr != null) throw Exception(delErr); if (selectedMembers.isNotEmpty) { final rows = selectedMembers .map((u) => {'team_id': team.id, 'user_id': u}) .toList(); final memRes = await client.from('team_members').insert(rows); final memErr = extractSupabaseError(memRes); if (memErr != null) throw Exception(memErr); // verify members persisted (handle Map or List response) final dynamic checkRes = await client .from('team_members') .select() .eq('team_id', team.id); if (checkRes is Map && checkRes['error'] != null) { throw Exception('Failed to verify team members'); } final List persisted = checkRes is List ? List.from(checkRes) : (checkRes is Map && checkRes['data'] is List) ? List.from(checkRes['data'] as List) : []; if (persisted.length != selectedMembers.length) { throw Exception('Member count mismatch after save'); } } } else { // create team and insert members final insertRes = await client .from('teams') .insert({ 'name': name, 'leader_id': leaderId, 'office_ids': selectedOffices, 'color': teamColor != null ? (teamColor!.toARGB32() & 0xFFFFFF) .toRadixString(16) .padLeft(6, '0') : null, }) .select() .single(); // normalize inserted row to extract id reliably across client response shapes dynamic insertedRow; final dynamic insertResValue = insertRes; if (insertResValue is List && insertResValue.isNotEmpty) { insertedRow = insertResValue.first; } else { final insertErr = extractSupabaseError(insertResValue); if (insertErr != null) throw Exception(insertErr); final dataField = insertResValue['data']; if (dataField is List && dataField.isNotEmpty) { insertedRow = dataField.first; } else if (dataField is Map) { insertedRow = dataField; } else if (insertResValue['id'] != null) { insertedRow = insertResValue; } } if (insertedRow == null || insertedRow['id'] == null) { throw Exception('Unable to determine created team id'); } final teamId = insertedRow['id'] as String; if (selectedMembers.isNotEmpty) { final rows = selectedMembers .map((u) => {'team_id': teamId, 'user_id': u}) .toList(); final memRes = await client.from('team_members').insert(rows); final memErr2 = extractSupabaseError(memRes); if (memErr2 != null) throw Exception(memErr2); // verify members persisted (handle Map or List response) final dynamic checkRes = await client .from('team_members') .select() .eq('team_id', teamId); if (checkRes is Map && checkRes['error'] != null) { throw Exception('Failed to verify team members'); } final List persisted = checkRes is List ? List.from(checkRes) : (checkRes is Map && checkRes['data'] is List) ? List.from(checkRes['data'] as List) : []; if (persisted.length != selectedMembers.length) { throw Exception('Member count mismatch after save'); } } } } catch (e) { if (!mounted) return; // ignore: use_build_context_synchronously showErrorSnackBar(context, 'Failed to save team: $e'); return; } ref.invalidate(teamsProvider); ref.invalidate(teamMembersProvider); navigator.pop(); } if (isMobileDialog) { // Mobile: bottom sheet presentation await m3ShowBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) { return StatefulBuilder( builder: (sheetContext, setState) { final navigator = Navigator.of(sheetContext); return SafeArea( child: Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 12, bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 12, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isEdit ? 'Edit Team' : 'Add Team', style: Theme.of(sheetContext).textTheme.titleLarge, ), const SizedBox(height: 8), buildFormContent(setState), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => navigator.pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( onPressed: () => onSave(setState, navigator), child: Text(isEdit ? 'Save' : 'Add'), ), ], ), ], ), ), ); }, ); }, ); } else { // Desktop / Tablet: centered fixed-width AlertDialog await m3ShowDialog( context: context, builder: (dialogContext) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: kDialogMaxWidth), child: StatefulBuilder( builder: (dialogContext, setState) { final navigator = Navigator.of(dialogContext); return AlertDialog( shape: AppSurfaces.of(dialogContext).dialogShape, title: Text(isEdit ? 'Edit Team' : 'Add Team'), content: buildFormContent(setState), actions: [ TextButton( onPressed: () => navigator.pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () => onSave(setState, navigator), child: Text(isEdit ? 'Save' : 'Add'), ), ], ); }, ), ), ); }, ); } } void _deleteTeam(BuildContext context, String teamId) async { final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) => AlertDialog( shape: AppSurfaces.of(dialogContext).dialogShape, title: const Text('Delete Team'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: const Text('Are you sure you want to delete this team?'), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Delete'), ), ], ), ); if (confirmed != true) return; final ctx = context; // capture before async gaps try { final delMembersRes = await ref .read(supabaseClientProvider) .from('team_members') .delete() .eq('team_id', teamId); final delMembersErr = extractSupabaseError(delMembersRes); if (delMembersErr != null) throw Exception(delMembersErr); final delTeamRes = await ref .read(supabaseClientProvider) .from('teams') .delete() .eq('id', teamId); final delTeamErr = extractSupabaseError(delTeamRes); if (delTeamErr != null) throw Exception(delTeamErr); ref.invalidate(teamsProvider); ref.invalidate(teamMembersProvider); } catch (e) { if (!mounted) return; // ignore: use_build_context_synchronously showErrorSnackBar(ctx, 'Failed to delete team: $e'); return; } } } /// Inline color picker for team color selection. class _TeamColorPicker extends StatelessWidget { const _TeamColorPicker({ required this.selectedColor, required this.onColorChanged, }); final Color? selectedColor; final ValueChanged onColorChanged; static const List _presetColors = [ Color(0xFFE53935), // Red Color(0xFFD81B60), // Pink Color(0xFF8E24AA), // Purple Color(0xFF5E35B1), // Deep Purple Color(0xFF3949AB), // Indigo Color(0xFF1E88E5), // Blue Color(0xFF039BE5), // Light Blue Color(0xFF00ACC1), // Cyan Color(0xFF00897B), // Teal Color(0xFF43A047), // Green Color(0xFF7CB342), // Light Green Color(0xFFFDD835), // Yellow Color(0xFFFFB300), // Amber Color(0xFFFB8C00), // Orange Color(0xFFF4511E), // Deep Orange Color(0xFF6D4C41), // Brown ]; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('Team Color', style: Theme.of(context).textTheme.bodyMedium), if (selectedColor != null) ...[ const SizedBox(width: 8), GestureDetector( onTap: () => onColorChanged(null), child: Text( 'Clear', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.primary, ), ), ), ], ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: _presetColors.map((color) { final isSelected = selectedColor != null && (selectedColor!.toARGB32() & 0xFFFFFF) == (color.toARGB32() & 0xFFFFFF); return GestureDetector( onTap: () => onColorChanged(color), child: Container( width: 32, height: 32, decoration: BoxDecoration( color: color, shape: BoxShape.circle, border: isSelected ? Border.all( color: Theme.of(context).colorScheme.onSurface, width: 3, ) : null, ), child: isSelected ? Icon(Icons.check, color: Colors.white, size: 18) : null, ), ); }).toList(), ), ], ); } }