diff --git a/lib/controller/directory/manage_bucket_controller.dart b/lib/controller/directory/manage_bucket_controller.dart index 7d2678f..d056a17 100644 --- a/lib/controller/directory/manage_bucket_controller.dart +++ b/lib/controller/directory/manage_bucket_controller.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class ManageBucketController extends GetxController { RxList allEmployees = [].obs; @@ -13,21 +14,100 @@ class ManageBucketController extends GetxController { fetchAllEmployees(); } + Future updateBucket({ + required String id, + required String name, + required String description, + required List employeeIds, + required List originalEmployeeIds, + }) async { + isLoading(true); + update(); + + try { + final updated = await ApiService.updateBucket( + id: id, + name: name, + description: description, + ); + + if (!updated) { + showAppSnackbar( + title: "Update Failed", + message: "Unable to update bucket details.", + type: SnackbarType.error, + ); + isLoading(false); + update(); + return false; + } + + // Build payload: mark new ones active, removed ones inactive + final allInvolvedIds = {...originalEmployeeIds, ...employeeIds}.toList(); + + final assignPayload = allInvolvedIds.map((empId) { + return { + "employeeId": empId, + "isActive": employeeIds.contains(empId), + }; + }).toList(); + + final assigned = await ApiService.assignEmployeesToBucket( + bucketId: id, + employees: assignPayload, + ); + + if (!assigned) { + showAppSnackbar( + title: "Assignment Failed", + message: "Employees couldn't be updated.", + type: SnackbarType.warning, + ); + } else { + showAppSnackbar( + title: "Success", + message: "Bucket updated successfully.", + type: SnackbarType.success, + ); + } + + return true; + } catch (e, stack) { + logSafe("Error in updateBucket: $e", level: LogLevel.error); + logSafe("Stack: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Unexpected Error", + message: "Please try again later.", + type: SnackbarType.error, + ); + return false; + } finally { + isLoading(false); + update(); + } + } + Future fetchAllEmployees() async { isLoading.value = true; try { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { - allEmployees.assignAll(response.map((json) => EmployeeModel.fromJson(json))); - logSafe("All Employees fetched for Manage Bucket: ${allEmployees.length}", level: LogLevel.info); + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); } else { allEmployees.clear(); - logSafe("No employees found for Manage Bucket.", level: LogLevel.warning); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); } } catch (e) { allEmployees.clear(); - logSafe("Error fetching employees in Manage Bucket", level: LogLevel.error, error: e); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); } isLoading.value = false; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index bb7c500..17328a6 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -44,4 +44,6 @@ class ApiEndpoints { static const String getDirectoryNotes = "/directory/notes"; static const String updateDirectoryNotes = "/directory/note"; static const String createBucket = "/directory/bucket"; + static const String updateBucket = "/directory/bucket"; + static const String assignBucket = "/directory/assign-bucket"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 411c594..23d3e7b 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -162,7 +162,8 @@ class ApiService { if (token == null) return null; final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); - logSafe("POST $uri\nHeaders: ${_headers(token)}\nBody: $body", + logSafe( + "POST $uri\nHeaders: ${_headers(token)}\nBody: $body", ); try { @@ -203,7 +204,9 @@ class ApiService { if (additionalHeaders != null) ...additionalHeaders, }; - logSafe("PUT $uri\nHeaders: $headers\nBody: $body", ); + logSafe( + "PUT $uri\nHeaders: $headers\nBody: $body", + ); try { final response = await http @@ -243,7 +246,89 @@ class ApiService { } /// Directory calling the API - + + + /// Update an existing bucket by ID + static Future updateBucket({ + required String id, + required String name, + required String description, + }) async { + final payload = { + "id": id, + "name": name, + "description": description, + }; + + final endpoint = "${ApiEndpoints.updateBucket}/$id"; + + logSafe("Updating bucket with payload: $payload"); + + try { + final response = await _putRequest(endpoint, payload); + + if (response == null) { + logSafe("Update bucket failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Update bucket response status: ${response.statusCode}"); + logSafe("Update bucket response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Bucket updated successfully: ${json['data']}"); + return true; + } else { + logSafe("Failed to update bucket: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during updateBucket API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + + /// Assign employees to a bucket + static Future assignEmployeesToBucket({ + required String bucketId, + required List> employees, + }) async { + final endpoint = "${ApiEndpoints.assignBucket}/$bucketId"; + + logSafe("Assigning employees to bucket $bucketId: $employees"); + + try { + final response = await _postRequest(endpoint, employees); + + if (response == null) { + logSafe("Assign employees failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Assign employees response status: ${response.statusCode}"); + logSafe("Assign employees response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Employees assigned successfully"); + return true; + } else { + logSafe("Failed to assign employees: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during assignEmployeesToBucket API: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future createBucket({ required String name, required String description, diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart index d3944ad..6926df5 100644 --- a/lib/helpers/widgets/team_members_bottom_sheet.dart +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -11,6 +11,14 @@ class TeamMembersBottomSheet { bool canEdit = false, VoidCallback? onEdit, }) { + // Ensure the owner is at the top of the list + final ownerId = bucket.createdBy.id; + members.sort((a, b) { + if (a.id == ownerId) return -1; + if (b.id == ownerId) return 1; + return 0; + }); + showModalBottomSheet( context: context, isScrollControlled: true, diff --git a/lib/model/directory/edit_bucket_bottom_sheet.dart b/lib/model/directory/edit_bucket_bottom_sheet.dart new file mode 100644 index 0000000..30bbc3c --- /dev/null +++ b/lib/model/directory/edit_bucket_bottom_sheet.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/directory/manage_bucket_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:collection/collection.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class EditBucketBottomSheet { + static void show(BuildContext context, ContactBucket bucket, + List allEmployees) { + final ManageBucketController controller = Get.find(); + + final nameController = TextEditingController(text: bucket.name); + final descController = TextEditingController(text: bucket.description); + final searchController = TextEditingController(); + + final selectedIds = RxSet({...bucket.employeeIds}); + final searchText = ''.obs; + + InputDecoration _inputDecoration(String label) { + return InputDecoration( + labelText: label, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + isDense: true, + ); + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return SingleChildScrollView( + padding: MediaQuery.of(context).viewInsets, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + MySpacing.height(12), + Center( + child: MyText.titleMedium('Edit Bucket', fontWeight: 700), + ), + MySpacing.height(24), + + // Bucket Name + TextField( + controller: nameController, + decoration: _inputDecoration('Bucket Name'), + ), + MySpacing.height(16), + + // Description + TextField( + controller: descController, + maxLines: 2, + decoration: _inputDecoration('Description'), + ), + MySpacing.height(20), + + // Shared With + Align( + alignment: Alignment.centerLeft, + child: MyText.labelLarge('Shared With', fontWeight: 600), + ), + MySpacing.height(8), + + // Search + Obx(() => TextField( + controller: searchController, + onChanged: (value) => + searchText.value = value.toLowerCase(), + decoration: InputDecoration( + hintText: 'Search employee...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: searchText.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + searchController.clear(); + searchText.value = ''; + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + borderSide: + BorderSide(color: Colors.blueAccent, width: 1.5), + ), + ), + )), + MySpacing.height(8), + + // Employee list + Obx(() { + final filtered = allEmployees.where((emp) { + final fullName = + '${emp.firstName} ${emp.lastName}'.toLowerCase(); + return fullName.contains(searchText.value); + }).toList(); + + return SizedBox( + height: 180, + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final emp = filtered[index]; + final fullName = + '${emp.firstName} ${emp.lastName}'.trim(); + + return Obx(() => Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + side: const BorderSide( + color: Colors.black, width: 2), + fillColor: + MaterialStateProperty.resolveWith( + (states) { + if (states + .contains(MaterialState.selected)) { + return Colors.blueAccent; + } + return Colors.transparent; + }), + checkColor: + MaterialStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + visualDensity: + const VisualDensity(vertical: -4), + controlAffinity: + ListTileControlAffinity.leading, + value: selectedIds.contains(emp.id), + onChanged: (val) { + if (val == true) { + selectedIds.add(emp.id); + } else { + selectedIds.remove(emp.id); + } + }, + title: Text( + fullName.isNotEmpty ? fullName : 'Unnamed', + style: const TextStyle(fontSize: 13), + ), + ), + )); + }, + ), + ); + }), + + MySpacing.height(24), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", + color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 12), + ), + ), + ), + MySpacing.width(12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + final newName = nameController.text.trim(); + final newDesc = descController.text.trim(); + final newEmployeeIds = selectedIds.toList()..sort(); + final originalEmployeeIds = [...bucket.employeeIds] + ..sort(); + + final nameChanged = newName != bucket.name; + final descChanged = newDesc != bucket.description; + final employeeChanged = !(ListEquality() + .equals(newEmployeeIds, originalEmployeeIds)); + + if (!nameChanged && + !descChanged && + !employeeChanged) { + showAppSnackbar( + title: "No Changes", + message: + "No changes were made to update the bucket.", + type: SnackbarType.warning, + ); + return; + } + + final success = await controller.updateBucket( + id: bucket.id, + name: newName, + description: newDesc, + employeeIds: newEmployeeIds, + originalEmployeeIds: originalEmployeeIds, + ); + + if (success) { + final directoryController = + Get.find(); + await directoryController.fetchBuckets(); + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium("Save", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/directory/manage_bucket_screen.dart b/lib/view/directory/manage_bucket_screen.dart index a5a0ce7..6cdd8ef 100644 --- a/lib/view/directory/manage_bucket_screen.dart +++ b/lib/view/directory/manage_bucket_screen.dart @@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/team_members_bottom_sheet.dart'; +import 'package:marco/model/directory/edit_bucket_bottom_sheet.dart'; class ManageBucketsScreen extends StatefulWidget { final PermissionController permissionController; @@ -188,54 +189,11 @@ class _ManageBucketsScreenState extends State { matchedEmployees, canEdit: canEdit, onEdit: () { - Get.defaultDialog( - title: '', - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - content: Column( - children: [ - const Icon(Icons.warning_amber, - size: 48, color: Colors.amber), - const SizedBox(height: 16), - Text( - 'Coming Soon', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - const SizedBox(height: 8), - Text( - 'This feature is under development and will be available soon.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey[700], - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - vertical: 12), - ), - onPressed: () => Get.back(), - child: const Text('OK'), - ), - ), - ], - ), - radius: 12, - ); - }, + Get.back(); // Close the bottom sheet first + Future.delayed(const Duration(milliseconds: 300), () { + EditBucketBottomSheet.show(context, bucket, manageBucketController.allEmployees); + }); +}, ); }, child: Row(