diff --git a/backend/src/controllers/memberController.js b/backend/src/controllers/memberController.js index 71db53e..abcf2ff 100644 --- a/backend/src/controllers/memberController.js +++ b/backend/src/controllers/memberController.js @@ -28,15 +28,7 @@ const addMemberToGroup = async (req, res) => { }); } - // Check if group is still forming - if (chitGroup.status !== 'forming') { - return res.status(400).json({ - success: false, - message: 'Cannot add members after chit group has started' - }); - } - - // Check if group is full + // Check if group is full (allow adding members to any status as long as not full) const currentMemberCount = await GroupMember.count({ where: { group_id: groupId, status: 'active' } }); @@ -44,7 +36,7 @@ const addMemberToGroup = async (req, res) => { if (currentMemberCount >= chitGroup.max_members) { return res.status(400).json({ success: false, - message: 'Chit group is full' + message: `Chit group is full (${currentMemberCount}/${chitGroup.max_members} members)` }); } diff --git a/luckychit/lib/interfaces/manager/manager_dashboard.dart b/luckychit/lib/interfaces/manager/manager_dashboard.dart index f973de4..5eb897c 100644 --- a/luckychit/lib/interfaces/manager/manager_dashboard.dart +++ b/luckychit/lib/interfaces/manager/manager_dashboard.dart @@ -14,6 +14,7 @@ import '../../features/notifications/notification_center_page.dart'; import 'chit_groups_page.dart'; import 'create_group_page.dart'; import 'import_existing_group_dialog.dart'; +import 'members_page.dart'; import '../../test_animated_draw.dart'; import '../../features/recordings/recordings_page.dart'; @@ -574,10 +575,7 @@ class ManagerDashboard extends StatelessWidget { } void _navigateToMembers() { - SnackbarUtil.showInfo( - 'Members page will be implemented next', - title: 'Coming Soon', - ); + Get.to(() => const MembersPage()); } void _navigateToPayments() { diff --git a/luckychit/lib/interfaces/manager/members_page.dart b/luckychit/lib/interfaces/manager/members_page.dart new file mode 100644 index 0000000..0d73e96 --- /dev/null +++ b/luckychit/lib/interfaces/manager/members_page.dart @@ -0,0 +1,709 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../../core/services/chit_group_service.dart'; +import '../../core/models/group_member.dart'; +import '../../core/models/chit_group.dart'; +import 'add_user_dialog.dart'; + +class MembersPage extends StatefulWidget { + const MembersPage({super.key}); + + @override + State createState() => _MembersPageState(); +} + +class _MembersPageState extends State { + final _chitGroupService = Get.find(); + final _searchController = TextEditingController(); + String _searchQuery = ''; + String _selectedFilter = 'all'; // all, active, inactive + + @override + void initState() { + super.initState(); + _loadAllMembers(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadAllMembers() async { + // Load all groups to get all members + await _chitGroupService.loadManagerChitGroups(); + } + + List> _getAllMembers() { + final allMembers = >[]; + final groups = _chitGroupService.chitGroups; + + for (var group in groups) { + if (group.members != null) { + for (var member in group.members!) { + // Check if member already in list (user can be in multiple groups) + final existingIndex = allMembers.indexWhere( + (m) => m['userId'] == member.userId, + ); + + if (existingIndex == -1) { + allMembers.add({ + 'userId': member.userId, + 'member': member, + 'groups': [group], + }); + } else { + // Add group to existing member's group list + (allMembers[existingIndex]['groups'] as List).add(group); + } + } + } + } + + return allMembers; + } + + List> _getFilteredMembers() { + var members = _getAllMembers(); + + // Apply status filter + if (_selectedFilter == 'active') { + members = members.where((m) { + final member = m['member'] as GroupMember; + return member.status.toLowerCase() == 'active'; + }).toList(); + } else if (_selectedFilter == 'inactive') { + members = members.where((m) { + final member = m['member'] as GroupMember; + return member.status.toLowerCase() != 'active'; + }).toList(); + } + + // Apply search filter + if (_searchQuery.isNotEmpty) { + members = members.where((m) { + final member = m['member'] as GroupMember; + final name = member.user?.fullName?.toLowerCase() ?? ''; + final mobile = member.user?.mobileNumber ?? ''; + final query = _searchQuery.toLowerCase(); + return name.contains(query) || mobile.contains(query); + }).toList(); + } + + return members; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: Text( + 'All Members', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + elevation: 2, + actions: [ + IconButton( + icon: Icon(Icons.person_add, size: 24.w), + onPressed: _showAddUserDialog, + tooltip: 'Add New Member', + ), + ], + ), + body: Obx(() { + if (_chitGroupService.isLoading.value) { + return Center( + child: CircularProgressIndicator( + color: Colors.green.shade600, + ), + ); + } + + final filteredMembers = _getFilteredMembers(); + + return Column( + children: [ + // Search and Filter Bar + Container( + padding: EdgeInsets.all(16.w), + color: Colors.white, + child: Column( + children: [ + // Search Bar + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or mobile...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _searchController.clear(); + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + filled: true, + fillColor: Colors.grey.shade50, + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 12.h, + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + SizedBox(height: 12.h), + + // Filter Chips + Row( + children: [ + Text( + 'Filter:', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + SizedBox(width: 12.w), + _buildFilterChip('All', 'all'), + SizedBox(width: 8.w), + _buildFilterChip('Active', 'active'), + SizedBox(width: 8.w), + _buildFilterChip('Inactive', 'inactive'), + ], + ), + ], + ), + ), + + // Member Count + Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), + color: Colors.blue.shade50, + child: Row( + children: [ + Icon(Icons.people, color: Colors.blue.shade700, size: 20.w), + SizedBox(width: 8.w), + Text( + '${filteredMembers.length} member${filteredMembers.length != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.blue.shade800, + ), + ), + ], + ), + ), + + // Members List + Expanded( + child: filteredMembers.isEmpty + ? _buildEmptyState() + : RefreshIndicator( + onRefresh: _loadAllMembers, + child: ListView.builder( + padding: EdgeInsets.all(16.w), + itemCount: filteredMembers.length, + itemBuilder: (context, index) { + final memberData = filteredMembers[index]; + final member = memberData['member'] as GroupMember; + final groups = memberData['groups'] as List; + + return _buildMemberCard(member, groups); + }, + ), + ), + ), + ], + ); + }), + ); + } + + Widget _buildFilterChip(String label, String value) { + final isSelected = _selectedFilter == value; + + return FilterChip( + label: Text( + label, + style: TextStyle( + fontSize: 14.sp, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedFilter = value; + }); + }, + selectedColor: Colors.green.shade100, + checkmarkColor: Colors.green.shade700, + backgroundColor: Colors.grey.shade100, + side: BorderSide( + color: isSelected ? Colors.green.shade600 : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), + ); + } + + Widget _buildMemberCard(GroupMember member, List groups) { + final user = member.user; + final isActive = member.status.toLowerCase() == 'active'; + + return Card( + margin: EdgeInsets.only(bottom: 12.h), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + child: InkWell( + onTap: () => _showMemberDetails(member, groups), + borderRadius: BorderRadius.circular(16.r), + child: Padding( + padding: EdgeInsets.all(16.w), + child: Row( + children: [ + // Avatar + CircleAvatar( + radius: 28.r, + backgroundColor: isActive ? Colors.green.shade600 : Colors.grey.shade400, + child: Text( + user?.fullName?.substring(0, 1).toUpperCase() ?? 'M', + style: TextStyle( + color: Colors.white, + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(width: 16.w), + + // Member Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.fullName ?? 'Unknown', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + SizedBox(height: 4.h), + Text( + user?.mobileNumber ?? '', + style: TextStyle( + fontSize: 14.sp, + color: Colors.grey.shade600, + ), + ), + SizedBox(height: 8.h), + Wrap( + spacing: 8.w, + children: [ + // Status Badge + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: isActive ? Colors.green.shade100 : Colors.grey.shade200, + borderRadius: BorderRadius.circular(8.r), + border: Border.all( + color: isActive ? Colors.green.shade600 : Colors.grey.shade400, + ), + ), + child: Text( + isActive ? 'Active' : 'Inactive', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: isActive ? Colors.green : Colors.grey.shade600, + ), + ), + ), + // Groups Count + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.group, size: 14.w, color: Colors.blue.shade700), + SizedBox(width: 4.w), + Text( + '${groups.length} group${groups.length != 1 ? 's' : ''}', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Arrow + Icon( + Icons.arrow_forward_ios, + size: 16.w, + color: Colors.grey.shade400, + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: EdgeInsets.all(32.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 80.w, + color: Colors.grey.shade300, + ), + SizedBox(height: 24.h), + Text( + _searchQuery.isNotEmpty + ? 'No members found' + : 'No members yet', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + SizedBox(height: 12.h), + Text( + _searchQuery.isNotEmpty + ? 'Try a different search term' + : 'Add members to your chit groups to see them here', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + ), + if (_searchQuery.isEmpty) ...[ + SizedBox(height: 32.h), + ElevatedButton.icon( + onPressed: _showAddUserDialog, + icon: Icon(Icons.person_add, size: 20.w), + label: Text( + 'Add Member', + style: TextStyle(fontSize: 16.sp), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + ), + ], + ], + ), + ), + ); + } + + void _showMemberDetails(GroupMember member, List groups) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24.r), + topRight: Radius.circular(24.r), + ), + ), + child: Column( + children: [ + // Handle + Container( + margin: EdgeInsets.only(top: 12.h), + width: 40.w, + height: 4.h, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2.r), + ), + ), + SizedBox(height: 20.h), + + // Header + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Row( + children: [ + CircleAvatar( + radius: 32.r, + backgroundColor: Colors.green.shade600, + child: Text( + member.user?.fullName?.substring(0, 1).toUpperCase() ?? 'M', + style: TextStyle( + color: Colors.white, + fontSize: 24.sp, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member.user?.fullName ?? 'Unknown', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + Text( + member.user?.mobileNumber ?? '', + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + SizedBox(height: 24.h), + + // Content + Expanded( + child: ListView( + padding: EdgeInsets.symmetric(horizontal: 24.w), + children: [ + // Stats + Row( + children: [ + Expanded( + child: _buildStatCard( + 'Total Paid', + '₹${_formatCurrency(member.totalPaid)}', + Icons.payment, + Colors.green, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildStatCard( + 'Total Won', + '₹${_formatCurrency(member.totalWon)}', + Icons.emoji_events, + Colors.amber, + ), + ), + ], + ), + SizedBox(height: 24.h), + + // Groups + Text( + 'Member of Groups (${groups.length})', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 12.h), + ...groups.map((group) => _buildGroupChip(group)), + + SizedBox(height: 24.h), + + // Additional Info + _buildInfoRow('Joined', _formatDate(member.joinedDate)), + SizedBox(height: 12.h), + _buildInfoRow('Status', member.status), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatCard(String label, String value, IconData icon, Color color) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 24.w), + SizedBox(height: 8.h), + Text( + value, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: color, + ), + ), + SizedBox(height: 4.h), + Text( + label, + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } + + Widget _buildGroupChip(ChitGroup group) { + return Container( + margin: EdgeInsets.only(bottom: 8.h), + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Icon( + Icons.account_balance_wallet, + color: Colors.green.shade600, + size: 20.w, + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.name, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${group.status} • ₹${_formatCurrency(group.monthlyInstallment)}/month', + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100.w, + child: Text( + label, + style: TextStyle( + fontSize: 14.sp, + color: Colors.grey.shade600, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ], + ); + } + + void _showAddUserDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AddUserDialog(), + ).then((result) { + if (result == true) { + // Reload members + _loadAllMembers(); + } + }); + } + + String _formatCurrency(double amount) { + final amountStr = amount.toStringAsFixed(0); + return amountStr.replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + } + + String _formatDate(DateTime date) { + return '${date.day}/${date.month}/${date.year}'; + } +} +