diff --git a/backend/migrations/20251106_add_member_number.sql b/backend/migrations/20251106_add_member_number.sql new file mode 100644 index 0000000..c17a710 --- /dev/null +++ b/backend/migrations/20251106_add_member_number.sql @@ -0,0 +1,35 @@ +-- Migration: Add member_number field to group_members table +-- Date: November 6, 2025 +-- Purpose: Add readable serial number for each member within a group + +-- Step 1: Add the column (allows NULL temporarily for existing data) +ALTER TABLE group_members +ADD COLUMN member_number INTEGER; + +-- Step 2: Populate member_number for existing members +-- This assigns sequential numbers based on joined_date (earliest = #1) +WITH numbered_members AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY joined_date ASC, created_at ASC + ) as assigned_number + FROM group_members +) +UPDATE group_members gm +SET member_number = nm.assigned_number +FROM numbered_members nm +WHERE gm.id = nm.id; + +-- Step 3: Make the column NOT NULL +ALTER TABLE group_members +ALTER COLUMN member_number SET NOT NULL; + +-- Step 4: Add unique constraint (group_id + member_number must be unique) +CREATE UNIQUE INDEX idx_group_members_member_number +ON group_members(group_id, member_number); + +-- Step 5: Add comment +COMMENT ON COLUMN group_members.member_number IS 'Readable serial number for the member within this group (1, 2, 3...)'; + diff --git a/backend/src/controllers/memberController.js b/backend/src/controllers/memberController.js index abcf2ff..b9a72f4 100644 --- a/backend/src/controllers/memberController.js +++ b/backend/src/controllers/memberController.js @@ -64,10 +64,20 @@ const addMemberToGroup = async (req, res) => { }); } + // Get the next member number for this group + const highestMember = await GroupMember.findOne({ + where: { group_id: groupId }, + order: [['member_number', 'DESC']], + attributes: ['member_number'] + }); + + const nextMemberNumber = highestMember ? highestMember.member_number + 1 : 1; + // Add member to group const groupMember = await GroupMember.create({ group_id: groupId, user_id: user.id, + member_number: nextMemberNumber, joined_date: new Date(), status: 'active', total_paid: 0, diff --git a/backend/src/models/GroupMember.js b/backend/src/models/GroupMember.js index d9179e8..18974a1 100644 --- a/backend/src/models/GroupMember.js +++ b/backend/src/models/GroupMember.js @@ -23,6 +23,11 @@ const GroupMember = sequelize.define('GroupMember', { key: 'id' } }, + member_number: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Readable serial number for the member within this group (1, 2, 3...)' + }, joined_date: { type: DataTypes.DATE, allowNull: false, @@ -55,6 +60,10 @@ const GroupMember = sequelize.define('GroupMember', { { unique: true, fields: ['group_id', 'user_id'] + }, + { + unique: true, + fields: ['group_id', 'member_number'] } ] }); diff --git a/luckychit/lib/core/models/group_member.dart b/luckychit/lib/core/models/group_member.dart index 5ee9e11..b34283c 100644 --- a/luckychit/lib/core/models/group_member.dart +++ b/luckychit/lib/core/models/group_member.dart @@ -4,6 +4,7 @@ class GroupMember { final String id; final String groupId; final String userId; + final int memberNumber; // Readable serial number (1, 2, 3...) final DateTime joinedDate; final String status; final double totalPaid; @@ -16,6 +17,7 @@ class GroupMember { required this.id, required this.groupId, required this.userId, + required this.memberNumber, required this.joinedDate, required this.status, required this.totalPaid, @@ -30,6 +32,7 @@ class GroupMember { id: json['id'] ?? '', groupId: json['group_id'] ?? '', userId: json['user_id'] ?? '', + memberNumber: _parseInt(json['member_number']), joinedDate: json['joined_date'] != null ? DateTime.parse(json['joined_date']) : DateTime.now(), status: json['status'] ?? 'active', totalPaid: _parseDouble(json['total_paid']), @@ -40,6 +43,23 @@ class GroupMember { ); } + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) { + try { + return int.parse(value); + } catch (e) { + print('Error parsing string to int: $value, error: $e'); + return 0; + } + } + if (value is double) return value.toInt(); + if (value is num) return value.toInt(); + print('Warning: Cannot parse $value (${value.runtimeType}) to int, returning 0'); + return 0; + } + static double _parseDouble(dynamic value) { if (value == null) return 0.0; if (value is double) return value; @@ -53,6 +73,7 @@ class GroupMember { 'id': id, 'group_id': groupId, 'user_id': userId, + 'member_number': memberNumber, 'joined_date': joinedDate.toIso8601String(), 'status': status, 'total_paid': totalPaid, @@ -67,4 +88,8 @@ class GroupMember { bool get isActive => status == 'active'; bool get isInactive => status == 'inactive'; bool get isRemoved => status == 'removed'; + + // Display formatted member number + String get displayNumber => '#$memberNumber'; + String get fullDisplay => 'Member #$memberNumber'; } diff --git a/luckychit/lib/core/models/user.dart b/luckychit/lib/core/models/user.dart index 986986f..02916af 100644 --- a/luckychit/lib/core/models/user.dart +++ b/luckychit/lib/core/models/user.dart @@ -5,6 +5,9 @@ class User { final String role; // 'manager' or 'member' final String? createdBy; // Manager who created this account final bool isActive; + final String? email; + final String? address; + final String? emergencyContact; final DateTime createdAt; final DateTime updatedAt; @@ -15,6 +18,9 @@ class User { required this.role, this.createdBy, required this.isActive, + this.email, + this.address, + this.emergencyContact, required this.createdAt, required this.updatedAt, }); @@ -27,6 +33,9 @@ class User { role: json['role'] ?? 'member', createdBy: json['created_by'], isActive: json['is_active'] ?? true, + email: json['email'], + address: json['address'], + emergencyContact: json['emergency_contact'], createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : DateTime.now(), updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : DateTime.now(), ); @@ -40,6 +49,9 @@ class User { 'role': role, 'created_by': createdBy, 'is_active': isActive, + if (email != null) 'email': email, + if (address != null) 'address': address, + if (emergencyContact != null) 'emergency_contact': emergencyContact, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -52,6 +64,9 @@ class User { String? role, String? createdBy, bool? isActive, + String? email, + String? address, + String? emergencyContact, DateTime? createdAt, DateTime? updatedAt, }) { @@ -62,6 +77,9 @@ class User { role: role ?? this.role, createdBy: createdBy ?? this.createdBy, isActive: isActive ?? this.isActive, + email: email ?? this.email, + address: address ?? this.address, + emergencyContact: emergencyContact ?? this.emergencyContact, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/luckychit/lib/core/services/api_service.dart b/luckychit/lib/core/services/api_service.dart index e7f8a83..fb4bf00 100644 --- a/luckychit/lib/core/services/api_service.dart +++ b/luckychit/lib/core/services/api_service.dart @@ -433,6 +433,24 @@ static const String tokenKey = 'auth_token'; } } + Future> updateMonthlyDraw(String drawId, Map data) async { + try { + final response = await _dio.put('/monthly-draws/$drawId', data: data); + return response.data; + } on DioException catch (e) { + return _handleError(e); + } + } + + Future> deleteMonthlyDraw(String drawId) async { + try { + final response = await _dio.delete('/monthly-draws/$drawId'); + return response.data; + } on DioException catch (e) { + return _handleError(e); + } + } + Future> getGroupFinancialData(String groupId) async { try { final response = await _dio.get('/chit-groups/$groupId/financial-data'); @@ -442,6 +460,15 @@ static const String tokenKey = 'auth_token'; } } + Future> updateMemberDetails(String memberId, Map data) async { + try { + final response = await _dio.put('/auth/member/$memberId', data: data); + return response.data; + } on DioException catch (e) { + return _handleError(e); + } + } + // Generic HTTP Methods Future> get(String path, {Map? queryParameters}) async { try { diff --git a/luckychit/lib/core/services/chit_group_service.dart b/luckychit/lib/core/services/chit_group_service.dart index bb59552..b6ac7b4 100644 --- a/luckychit/lib/core/services/chit_group_service.dart +++ b/luckychit/lib/core/services/chit_group_service.dart @@ -110,6 +110,53 @@ class ChitGroupService extends GetxController { } } + // Update chit group + Future updateChitGroup(String groupId, Map data) async { + try { + _isLoading.value = true; + + final response = await _apiService.updateChitGroup(groupId, data); + + if (response['success']) { + await loadChitGroupDetails(groupId); + return true; + } else { + Get.snackbar('Error', response['message']); + return false; + } + } catch (e) { + print('Error updating chit group: $e'); + Get.snackbar('Error', 'Failed to update chit group'); + return false; + } finally { + _isLoading.value = false; + } + } + + // Delete chit group + Future deleteChitGroup(String groupId) async { + try { + _isLoading.value = true; + + final response = await _apiService.deleteChitGroup(groupId); + + if (response['success']) { + _chitGroups.removeWhere((g) => g.id == groupId); + Get.snackbar('Success', 'Chit group deleted successfully'); + return true; + } else { + Get.snackbar('Error', response['message']); + return false; + } + } catch (e) { + print('Error deleting chit group: $e'); + Get.snackbar('Error', 'Failed to delete chit group'); + return false; + } finally { + _isLoading.value = false; + } + } + // Load chit group details Future loadChitGroupDetails(String groupId) async { try { diff --git a/luckychit/lib/interfaces/manager/edit_draw_dialog.dart b/luckychit/lib/interfaces/manager/edit_draw_dialog.dart new file mode 100644 index 0000000..f40c8d9 --- /dev/null +++ b/luckychit/lib/interfaces/manager/edit_draw_dialog.dart @@ -0,0 +1,456 @@ +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/services/api_service.dart'; +import '../../core/models/monthly_draw.dart'; +import '../../core/utils/snackbar_util.dart'; + +class EditDrawDialog extends StatefulWidget { + final MonthlyDraw draw; + + const EditDrawDialog({ + super.key, + required this.draw, + }); + + @override + State createState() => _EditDrawDialogState(); +} + +class _EditDrawDialogState extends State { + final _formKey = GlobalKey(); + final _prizeAmountController = TextEditingController(); + final _notesController = TextEditingController(); + final _apiService = ApiService(); + + String? _selectedMemberId; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _selectedMemberId = widget.draw.winnerId; + _prizeAmountController.text = widget.draw.prizeAmount.toStringAsFixed(0); + _notesController.text = widget.draw.notes ?? ''; + } + + @override + void dispose() { + _prizeAmountController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + if (_formKey.currentState!.validate()) { + if (_selectedMemberId == null) { + SnackbarUtil.showError('Please select a winner'); + return; + } + + setState(() => _isLoading = true); + + try { + final updates = {}; + + // Only include changed fields + if (_selectedMemberId != widget.draw.winnerId) { + updates['winner_id'] = _selectedMemberId; + } + + final newPrizeAmount = double.parse(_prizeAmountController.text); + if (newPrizeAmount != widget.draw.prizeAmount) { + updates['prize_amount'] = newPrizeAmount; + } + + if (_notesController.text.isNotEmpty && _notesController.text != widget.draw.notes) { + updates['notes'] = _notesController.text; + } + + if (updates.isEmpty) { + SnackbarUtil.showWarning('No changes made'); + Get.back(); + return; + } + + final response = await _apiService.updateMonthlyDraw(widget.draw.id, updates); + + if (response['success']) { + SnackbarUtil.showSuccess( + 'Draw updated successfully', + title: 'Success', + ); + Get.back(result: true); + } else { + SnackbarUtil.showError( + response['message'] ?? 'Failed to update draw', + title: 'Error', + ); + } + } catch (e) { + SnackbarUtil.showError('Error: ${e.toString()}'); + } finally { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.r)), + child: Container( + width: 500.w, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Fixed Header + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20.r), + topRight: Radius.circular(20.r), + ), + border: Border( + bottom: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Icon(Icons.edit, color: Colors.orange.shade600, size: 28.w), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Draw Result', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Month ${widget.draw.month}/${widget.draw.year}', + style: TextStyle( + fontSize: 14.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + ), + + // Scrollable Content + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.all(24.w), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Warning Message + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.orange.shade200), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: Colors.orange.shade700, size: 24.w), + SizedBox(width: 12.w), + Expanded( + child: Text( + 'Carefully verify changes before saving. This will update the official draw record.', + style: TextStyle( + color: Colors.orange.shade900, + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + SizedBox(height: 24.h), + + // Select Winner + Text( + 'Winner', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 12.h), + Obx(() { + final members = Get.find().groupMembers; + + if (members.isEmpty) { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + 'Loading members...', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14.sp, + ), + ), + ); + } + + return Container( + constraints: BoxConstraints( + maxHeight: 250.h, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12.r), + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + final isSelected = member.userId == _selectedMemberId; + final isCurrentWinner = member.userId == widget.draw.winnerId; + final isLast = index == members.length - 1; + + return InkWell( + onTap: () { + setState(() { + _selectedMemberId = member.userId; + }); + }, + borderRadius: BorderRadius.vertical( + top: index == 0 ? Radius.circular(12.r) : Radius.zero, + bottom: isLast ? Radius.circular(12.r) : Radius.zero, + ), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 14.h, + ), + decoration: BoxDecoration( + color: isSelected ? Colors.green.shade50 : null, + border: !isLast ? Border( + bottom: BorderSide(color: Colors.grey.shade200), + ) : null, + ), + child: Row( + children: [ + Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: isSelected ? Colors.green.shade600 : Colors.grey.shade400, + size: 24.w, + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + member.user?.fullName ?? 'Unknown', + style: TextStyle( + fontSize: 16.sp, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + if (isCurrentWinner) ...[ + Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: 2.h, + ), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(4.r), + ), + child: Text( + 'Current', + style: TextStyle( + fontSize: 10.sp, + color: Colors.blue.shade800, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + if (member.user?.mobileNumber != null) ...[ + SizedBox(height: 2.h), + Text( + member.user!.mobileNumber, + style: TextStyle( + fontSize: 13.sp, + color: Colors.grey.shade600, + ), + ), + ], + ], + ), + ), + if (isSelected) + Icon(Icons.check_circle, + color: Colors.green.shade600, size: 20.w), + ], + ), + ), + ); + }, + ), + ); + }), + SizedBox(height: 20.h), + + // Prize Amount + Text( + 'Prize Amount', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _prizeAmountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Prize amount', + prefixText: '₹ ', + prefixIcon: const Icon(Icons.currency_rupee), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (double.tryParse(value!) == null) return 'Invalid amount'; + return null; + }, + ), + SizedBox(height: 20.h), + + // Notes (Optional) + Text( + 'Notes (Optional)', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _notesController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Reason for editing (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + ), + ], + ), + ), + ), + ), + + // Fixed Footer with Actions + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20.r), + bottomRight: Radius.circular(20.r), + ), + border: Border( + top: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Get.back(), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + 'Cancel', + style: TextStyle(fontSize: 16.sp), + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange.shade600, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 2, + ), + child: _isLoading + ? SizedBox( + height: 20.h, + width: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Update Draw', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + diff --git a/luckychit/lib/interfaces/manager/edit_group_dialog.dart b/luckychit/lib/interfaces/manager/edit_group_dialog.dart new file mode 100644 index 0000000..bd5b69d --- /dev/null +++ b/luckychit/lib/interfaces/manager/edit_group_dialog.dart @@ -0,0 +1,539 @@ +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/chit_group.dart'; +import '../../core/utils/snackbar_util.dart'; + +class EditGroupDialog extends StatefulWidget { + final ChitGroup group; + + const EditGroupDialog({ + super.key, + required this.group, + }); + + @override + State createState() => _EditGroupDialogState(); +} + +class _EditGroupDialogState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _totalValueController = TextEditingController(); + final _monthlyInstallmentController = TextEditingController(); + final _durationController = TextEditingController(); + final _maxMembersController = TextEditingController(); + final _commissionController = TextEditingController(); + final _drawDateController = TextEditingController(); + final _service = ChitGroupService.to; + + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _nameController.text = widget.group.name; + _totalValueController.text = widget.group.totalValue.toStringAsFixed(0); + _monthlyInstallmentController.text = widget.group.monthlyInstallment.toStringAsFixed(0); + _durationController.text = widget.group.durationMonths.toString(); + _maxMembersController.text = widget.group.maxMembers.toString(); + _commissionController.text = widget.group.foremanCommissionAmount.toStringAsFixed(0); + _drawDateController.text = widget.group.drawDate.toString(); + } + + @override + void dispose() { + _nameController.dispose(); + _totalValueController.dispose(); + _monthlyInstallmentController.dispose(); + _durationController.dispose(); + _maxMembersController.dispose(); + _commissionController.dispose(); + _drawDateController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + if (_formKey.currentState!.validate()) { + setState(() => _isLoading = true); + + try { + final updates = {}; + + // Only include changed fields + if (_nameController.text != widget.group.name) { + updates['name'] = _nameController.text; + } + + final newTotalValue = double.parse(_totalValueController.text); + if (newTotalValue != widget.group.totalValue) { + updates['total_value'] = newTotalValue; + } + + final newInstallment = double.parse(_monthlyInstallmentController.text); + if (newInstallment != widget.group.monthlyInstallment) { + updates['monthly_installment'] = newInstallment; + } + + final newDuration = int.parse(_durationController.text); + if (newDuration != widget.group.durationMonths) { + updates['duration_months'] = newDuration; + } + + final newMaxMembers = int.parse(_maxMembersController.text); + if (newMaxMembers != widget.group.maxMembers) { + updates['max_members'] = newMaxMembers; + } + + final newCommission = double.parse(_commissionController.text); + if (newCommission != widget.group.foremanCommissionAmount) { + updates['foreman_commission_amount'] = newCommission; + } + + final newDrawDate = int.parse(_drawDateController.text); + if (newDrawDate != widget.group.drawDate) { + updates['draw_date'] = newDrawDate; + } + + if (updates.isEmpty) { + SnackbarUtil.showWarning('No changes made'); + Get.back(); + return; + } + + final response = await _service.updateChitGroup(widget.group.id, updates); + + if (response) { + SnackbarUtil.showSuccess( + 'Group details updated successfully', + title: 'Success', + ); + Get.back(result: true); + } + } catch (e) { + SnackbarUtil.showError('Error: ${e.toString()}'); + } finally { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.r)), + child: Container( + width: 500.w, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Fixed Header + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20.r), + topRight: Radius.circular(20.r), + ), + border: Border( + bottom: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Icon(Icons.edit, color: Colors.green.shade600, size: 28.w), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Group Details', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Only works for forming groups', + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + ), + + // Scrollable Content + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.all(24.w), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Group Name + Text( + 'Group Name *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + hintText: 'Group name', + prefixIcon: const Icon(Icons.group), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Name is required'; + if (value!.length < 3) return 'Name too short'; + return null; + }, + ), + SizedBox(height: 20.h), + + // Financial Details Row 1 + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Value *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _totalValueController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Total value', + prefixText: '₹ ', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (double.tryParse(value!) == null) return 'Invalid'; + return null; + }, + ), + ], + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Monthly Installment *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _monthlyInstallmentController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Installment', + prefixText: '₹ ', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (double.tryParse(value!) == null) return 'Invalid'; + return null; + }, + ), + ], + ), + ), + ], + ), + SizedBox(height: 20.h), + + // Financial Details Row 2 + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Duration (Months) *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _durationController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Months', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + final num = int.tryParse(value!); + if (num == null || num < 1) return 'Invalid'; + return null; + }, + ), + ], + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Max Members *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _maxMembersController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Members', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + final num = int.tryParse(value!); + if (num == null || num < 1) return 'Invalid'; + return null; + }, + ), + ], + ), + ), + ], + ), + SizedBox(height: 20.h), + + // Commission and Draw Date Row + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Commission Amount *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _commissionController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Commission', + prefixText: '₹ ', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + if (double.tryParse(value!) == null) return 'Invalid'; + return null; + }, + ), + ], + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Draw Date *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _drawDateController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Day (1-31)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + final num = int.tryParse(value!); + if (num == null || num < 1 || num > 31) { + return '1-31 only'; + } + return null; + }, + ), + ], + ), + ), + ], + ), + SizedBox(height: 20.h), + + // Info Note + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( + children: [ + Icon(Icons.info_outline, + color: Colors.blue.shade700, size: 20.w), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'Group can only be edited while in "Forming" status. Once started, it cannot be changed.', + style: TextStyle( + fontSize: 12.sp, + color: Colors.blue.shade900, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + // Fixed Footer with Actions + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20.r), + bottomRight: Radius.circular(20.r), + ), + border: Border( + top: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Get.back(), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + 'Cancel', + style: TextStyle(fontSize: 16.sp), + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 2, + ), + child: _isLoading + ? SizedBox( + height: 20.h, + width: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Update Group', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + diff --git a/luckychit/lib/interfaces/manager/edit_member_dialog.dart b/luckychit/lib/interfaces/manager/edit_member_dialog.dart new file mode 100644 index 0000000..911712a --- /dev/null +++ b/luckychit/lib/interfaces/manager/edit_member_dialog.dart @@ -0,0 +1,562 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter/services.dart'; +import '../../core/services/api_service.dart'; +import '../../core/models/group_member.dart'; +import '../../core/utils/snackbar_util.dart'; + +class EditMemberDialog extends StatefulWidget { + final GroupMember member; + + const EditMemberDialog({ + super.key, + required this.member, + }); + + @override + State createState() => _EditMemberDialogState(); +} + +class _EditMemberDialogState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _emailController = TextEditingController(); + final _addressController = TextEditingController(); + final _emergencyContactController = TextEditingController(); + final _apiService = ApiService(); + + bool _isLoading = false; + bool _showMemberId = false; + + @override + void initState() { + super.initState(); + _nameController.text = widget.member.user?.fullName ?? ''; + _phoneController.text = widget.member.user?.mobileNumber ?? ''; + _emailController.text = widget.member.user?.email ?? ''; + _addressController.text = widget.member.user?.address ?? ''; + _emergencyContactController.text = widget.member.user?.emergencyContact ?? ''; + } + + @override + void dispose() { + _nameController.dispose(); + _phoneController.dispose(); + _emailController.dispose(); + _addressController.dispose(); + _emergencyContactController.dispose(); + super.dispose(); + } + + Future _handleSubmit() async { + if (_formKey.currentState!.validate()) { + setState(() => _isLoading = true); + + try { + final updates = {}; + + // Only include changed fields + if (_nameController.text != (widget.member.user?.fullName ?? '')) { + updates['full_name'] = _nameController.text; + } + + if (_phoneController.text != (widget.member.user?.mobileNumber ?? '')) { + updates['mobile_number'] = _phoneController.text; + } + + if (_emailController.text != (widget.member.user?.email ?? '')) { + updates['email'] = _emailController.text.isEmpty ? null : _emailController.text; + } + + if (_addressController.text != (widget.member.user?.address ?? '')) { + updates['address'] = _addressController.text.isEmpty ? null : _addressController.text; + } + + if (_emergencyContactController.text != (widget.member.user?.emergencyContact ?? '')) { + updates['emergency_contact'] = _emergencyContactController.text.isEmpty + ? null + : _emergencyContactController.text; + } + + if (updates.isEmpty) { + SnackbarUtil.showWarning('No changes made'); + Get.back(); + return; + } + + final response = await _apiService.updateMemberDetails( + widget.member.userId, + updates, + ); + + if (response['success']) { + SnackbarUtil.showSuccess( + 'Member details updated successfully', + title: 'Success', + ); + Get.back(result: true); + } else { + SnackbarUtil.showError( + response['message'] ?? 'Failed to update member', + title: 'Error', + ); + } + } catch (e) { + SnackbarUtil.showError('Error: ${e.toString()}'); + } finally { + setState(() => _isLoading = false); + } + } + } + + void _copyMemberId() { + Clipboard.setData(ClipboardData(text: widget.member.userId)); + SnackbarUtil.showSuccess( + 'Member ID copied to clipboard', + duration: const Duration(seconds: 2), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.r)), + child: Container( + width: 500.w, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Fixed Header + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20.r), + topRight: Radius.circular(20.r), + ), + border: Border( + bottom: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + // Member Number Badge + Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: Colors.purple.shade600, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '#${widget.member.memberNumber}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16.sp, + ), + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Member #${widget.member.memberNumber}', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.member.user?.fullName ?? 'Unknown', + style: TextStyle( + fontSize: 13.sp, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + ), + + // Scrollable Content + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.all(24.w), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Member Number & ID Section + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.purple.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.badge, + color: Colors.purple.shade700, size: 22.w), + SizedBox(width: 8.w), + Text( + 'Member Number (Readable)', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: Colors.purple.shade900, + ), + ), + ], + ), + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.purple.shade300, width: 2), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Member #${widget.member.memberNumber}', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + color: Colors.purple.shade700, + ), + ), + ], + ), + ), + SizedBox(height: 8.h), + Text( + 'Use this number to easily reference this member', + style: TextStyle( + fontSize: 12.sp, + color: Colors.purple.shade700, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + SizedBox(height: 16.h), + + // Unique ID Section (Technical) + Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12.r), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.fingerprint, + color: Colors.grey.shade700, size: 20.w), + SizedBox(width: 8.w), + Text( + 'Unique ID (Technical)', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const Spacer(), + IconButton( + icon: Icon(_showMemberId ? Icons.visibility_off : Icons.visibility, + size: 18.w), + onPressed: () { + setState(() => _showMemberId = !_showMemberId); + }, + tooltip: _showMemberId ? 'Hide ID' : 'Show ID', + ), + ], + ), + if (_showMemberId) ...[ + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: Text( + widget.member.userId, + style: TextStyle( + fontSize: 11.sp, + fontFamily: 'monospace', + color: Colors.grey.shade800, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: _copyMemberId, + tooltip: 'Copy UUID', + ), + ], + ), + ], + SizedBox(height: 4.h), + Text( + 'UUID for database operations', + style: TextStyle( + fontSize: 11.sp, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + SizedBox(height: 24.h), + + // Full Name + Text( + 'Full Name *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + hintText: 'Full name', + prefixIcon: const Icon(Icons.person), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Name is required'; + if (value!.length < 2) return 'Name too short'; + return null; + }, + ), + SizedBox(height: 20.h), + + // Mobile Number + Text( + 'Mobile Number *', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + maxLength: 10, + decoration: InputDecoration( + hintText: '10-digit mobile number', + prefixIcon: const Icon(Icons.phone), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + counterText: '', + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Mobile number is required'; + if (!RegExp(r'^[0-9]{10}$').hasMatch(value!)) { + return 'Must be exactly 10 digits'; + } + return null; + }, + ), + SizedBox(height: 20.h), + + // Email (Optional) + Text( + 'Email (Optional)', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'email@example.com', + prefixIcon: const Icon(Icons.email), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + return 'Invalid email format'; + } + } + return null; + }, + ), + SizedBox(height: 20.h), + + // Address (Optional) + Text( + 'Address (Optional)', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _addressController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Full address', + prefixIcon: const Icon(Icons.location_on), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + ), + ), + SizedBox(height: 20.h), + + // Emergency Contact (Optional) + Text( + 'Emergency Contact (Optional)', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + TextFormField( + controller: _emergencyContactController, + keyboardType: TextInputType.phone, + maxLength: 10, + decoration: InputDecoration( + hintText: '10-digit emergency contact', + prefixIcon: const Icon(Icons.contacts), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.r), + ), + filled: true, + fillColor: Colors.grey.shade50, + counterText: '', + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (!RegExp(r'^[0-9]{10}$').hasMatch(value)) { + return 'Must be exactly 10 digits'; + } + } + return null; + }, + ), + ], + ), + ), + ), + ), + + // Fixed Footer with Actions + Container( + padding: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20.r), + bottomRight: Radius.circular(20.r), + ), + border: Border( + top: BorderSide(color: Colors.grey.shade200), + ), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Get.back(), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + ), + child: Text( + 'Cancel', + style: TextStyle(fontSize: 16.sp), + ), + ), + ), + SizedBox(width: 16.w), + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(vertical: 14.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r), + ), + elevation: 2, + ), + child: _isLoading + ? SizedBox( + height: 20.h, + width: 20.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + 'Update Member', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + diff --git a/luckychit/lib/interfaces/manager/group_details_page.dart b/luckychit/lib/interfaces/manager/group_details_page.dart index 4aca808..c63013f 100644 --- a/luckychit/lib/interfaces/manager/group_details_page.dart +++ b/luckychit/lib/interfaces/manager/group_details_page.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../core/services/chit_group_service.dart'; import '../../core/services/payment_service.dart'; +import '../../core/services/api_service.dart'; import '../../core/models/chit_group.dart'; import '../../core/models/group_member.dart'; import '../../core/models/payment.dart'; @@ -16,6 +17,9 @@ import 'add_past_draw_dialog.dart'; import 'add_past_payments_dialog.dart'; import 'record_payment_dialog.dart'; import 'payment_history_page.dart'; +import 'edit_draw_dialog.dart'; +import 'edit_member_dialog.dart'; +import 'edit_group_dialog.dart'; import '../../features/chitfund_schedule/chitfund_schedule_page.dart'; import '../../features/monthly_payments/monthly_payment_status_page.dart'; @@ -93,11 +97,15 @@ class _GroupDetailsPageState extends State with SingleTickerPr elevation: 0, actions: [ // Actions menu - always visible - PopupMenuButton( + PopupMenuButton( icon: Icon(Icons.more_vert, size: 24.w, color: Colors.grey.shade700), tooltip: 'More Options', - onSelected: (value) { - if (value == 'select') { + onSelected: (value) { + if (value == 'edit_group') { + _editGroup(); + } else if (value == 'delete_group') { + _confirmDeleteGroup(); + } else if (value == 'select') { _showMemberSelectionDialog(context); } else if (value == 'add_user') { _showAddUserDialog(context); @@ -113,28 +121,52 @@ class _GroupDetailsPageState extends State with SingleTickerPr final canAddMembers = currentMemberCount < maxMembers; return [ + // Edit/Delete group options (only for forming groups) + if (widget.group.status == 'forming') ...[ + PopupMenuItem( + value: 'edit_group', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.green.shade600), + SizedBox(width: 12.w), + const Text('Edit Group Details'), + ], + ), + ), + PopupMenuItem( + value: 'delete_group', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red.shade600), + SizedBox(width: 12.w), + const Text('Delete Group'), + ], + ), + ), + const PopupMenuDivider(), + ], // Add members options (if not full) if (canAddMembers) ...[ - PopupMenuItem( - value: 'select', - child: Row( - children: [ - Icon(Icons.people_alt, color: Colors.blue.shade600), + PopupMenuItem( + value: 'select', + child: Row( + children: [ + Icon(Icons.people_alt, color: Colors.blue.shade600), SizedBox(width: 12.w), - const Text('Select Members'), - ], + const Text('Select Members'), + ], + ), ), - ), - PopupMenuItem( - value: 'add_user', - child: Row( - children: [ - Icon(Icons.person_add, color: Colors.green.shade600), + PopupMenuItem( + value: 'add_user', + child: Row( + children: [ + Icon(Icons.person_add, color: Colors.green.shade600), SizedBox(width: 12.w), - const Text('Add New User'), - ], + const Text('Add New User'), + ], + ), ), - ), const PopupMenuDivider(), ], // Backfill options (always available) @@ -1056,12 +1088,45 @@ class _GroupDetailsPageState extends State with SingleTickerPr ], ), ), - Text( - '${draw.drawDate.day}/${draw.drawDate.month}/${draw.drawDate.year}', - style: TextStyle( - fontSize: 12.sp, - color: Colors.grey.shade600, - ), + Column( + children: [ + Text( + '${draw.drawDate.day}/${draw.drawDate.month}/${draw.drawDate.year}', + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey.shade600, + ), + ), + SizedBox(height: 8.h), + // Action Buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.edit, size: 20.w), + color: Colors.orange.shade600, + tooltip: 'Edit Draw', + onPressed: () => _editDraw(draw), + padding: EdgeInsets.all(8.w), + constraints: BoxConstraints( + minWidth: 36.w, + minHeight: 36.h, + ), + ), + IconButton( + icon: Icon(Icons.delete, size: 20.w), + color: Colors.red.shade600, + tooltip: 'Delete Draw', + onPressed: () => _confirmDeleteDraw(draw), + padding: EdgeInsets.all(8.w), + constraints: BoxConstraints( + minWidth: 36.w, + minHeight: 36.h, + ), + ), + ], + ), + ], ), ], ), @@ -1459,17 +1524,29 @@ class _GroupDetailsPageState extends State with SingleTickerPr ), child: Row( children: [ - CircleAvatar( - radius: 24.r, - backgroundColor: Colors.blue.shade100, - child: Text( - (member.user?.fullName.isNotEmpty == true - ? member.user!.fullName.substring(0, 1) - : 'M').toUpperCase(), - style: TextStyle( - color: Colors.blue.shade700, - fontWeight: FontWeight.w600, - fontSize: 16.sp, + // Member Number Badge + Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: Colors.purple.shade600, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.purple.shade200, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + '#${member.memberNumber}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18.sp, + ), ), ), ), @@ -1478,13 +1555,35 @@ class _GroupDetailsPageState extends State with SingleTickerPr child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - member.user?.fullName ?? 'Unknown Member', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16.sp, - color: Colors.black87, - ), + Row( + children: [ + Expanded( + child: Text( + member.user?.fullName ?? 'Unknown Member', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.sp, + color: Colors.black87, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: Colors.purple.shade200), + ), + child: Text( + 'Member #${member.memberNumber}', + style: TextStyle( + fontSize: 11.sp, + color: Colors.purple.shade700, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), SizedBox(height: 4.h), Text( @@ -1523,6 +1622,17 @@ class _GroupDetailsPageState extends State with SingleTickerPr ], ), ), + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 18, color: Colors.blue), + SizedBox(width: 8), + Text('Edit Member', style: TextStyle(color: Colors.blue)), + ], + ), + ), + const PopupMenuDivider(), if (member.status == 'active') const PopupMenuItem( value: 'suspend', @@ -1832,6 +1942,176 @@ class _GroupDetailsPageState extends State with SingleTickerPr ); } + void _editDraw(MonthlyDraw draw) { + showDialog( + context: context, + builder: (context) => EditDrawDialog(draw: draw), + ).then((result) { + if (result == true) { + _service.loadGroupMonthlyDraws(widget.group.id); + } + }); + } + + void _confirmDeleteDraw(MonthlyDraw draw) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Draw?'), + content: Text( + 'Are you sure you want to delete the draw for ${draw.month}/${draw.year}?\n\n' + 'Winner: ${draw.winner?.fullName ?? "Unknown"}\n' + 'Prize: ${_formatIndianCurrency(draw.prizeAmount)}\n\n' + 'This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _deleteDraw(draw); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } + + Future _deleteDraw(MonthlyDraw draw) async { + try { + final apiService = ApiService(); + final response = await apiService.deleteMonthlyDraw(draw.id); + + if (response['success']) { + Get.snackbar( + 'Success', + 'Draw deleted successfully', + backgroundColor: Colors.green, + colorText: Colors.white, + ); + await _service.loadGroupMonthlyDraws(widget.group.id); + } else { + Get.snackbar( + 'Error', + response['message'] ?? 'Failed to delete draw', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } catch (e) { + Get.snackbar( + 'Error', + 'Failed to delete draw: ${e.toString()}', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + void _editGroup() { + if (widget.group.status != 'forming') { + Get.snackbar( + 'Cannot Edit', + 'Group can only be edited while in "Forming" status', + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + return; + } + + showDialog( + context: context, + builder: (context) => EditGroupDialog(group: widget.group), + ).then((result) { + if (result == true) { + _service.loadChitGroupDetails(widget.group.id); + _service.loadManagerChitGroups(); + } + }); + } + + void _confirmDeleteGroup() { + if (widget.group.status != 'forming') { + Get.snackbar( + 'Cannot Delete', + 'Group can only be deleted while in "Forming" status', + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + return; + } + + final memberCount = _service.groupMembers.length; + if (memberCount > 0) { + Get.snackbar( + 'Cannot Delete', + 'Group has $memberCount members. Remove all members first.', + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + return; + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Group?'), + content: Text( + 'Are you sure you want to delete "${widget.group.name}"?\n\n' + 'This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _deleteGroup(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } + + Future _deleteGroup() async { + try { + final success = await _service.deleteChitGroup(widget.group.id); + + if (success) { + Get.back(); // Close group details page + Get.snackbar( + 'Success', + 'Group deleted successfully', + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } + } catch (e) { + Get.snackbar( + 'Error', + 'Failed to delete group: ${e.toString()}', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + void _startChitfund() async { final success = await _service.startChitGroup(widget.group.id); if (success) { @@ -1857,6 +2137,9 @@ class _GroupDetailsPageState extends State with SingleTickerPr case 'view': Get.snackbar('Coming Soon', 'Member details page will be implemented next'); break; + case 'edit': + _editMember(member); + break; case 'suspend': _updateMemberStatus(member.id, 'suspended'); break; @@ -1869,6 +2152,17 @@ class _GroupDetailsPageState extends State with SingleTickerPr } } + void _editMember(GroupMember member) { + showDialog( + context: context, + builder: (context) => EditMemberDialog(member: member), + ).then((result) { + if (result == true) { + _service.loadGroupMembers(widget.group.id); + } + }); + } + void _updateMemberStatus(String memberId, String status) async { final success = await _service.updateMemberStatus(widget.group.id, memberId, status); if (success) {