diff --git a/lib/controller/service_project/service_project_allocation_controller.dart b/lib/controller/service_project/service_project_allocation_controller.dart new file mode 100644 index 0000000..a59b399 --- /dev/null +++ b/lib/controller/service_project/service_project_allocation_controller.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/model/service_project/job_allocation_model.dart'; +import 'package:marco/helpers/services/api_service.dart'; + +class ServiceProjectAllocationController extends GetxController { + final projectId = ''.obs; + + // Roles + var roles = [].obs; + var selectedRole = Rxn(); + + // Employees + var roleEmployees = [].obs; + var selectedEmployees = [].obs; + final displayController = TextEditingController(); + + // Loading + var isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + ever(selectedEmployees, (_) { + displayController.text = selectedEmployees.isEmpty + ? '' + : selectedEmployees + .map((e) => '${e.firstName} ${e.lastName}') + .join(', '); + }); + } + + // Fetch all roles + Future fetchRoles() async { + isLoading.value = true; + final result = await ApiService.getTeamRoles(); + if (result != null) { + roles.assignAll(result); + } + isLoading.value = false; + } + + // Fetch employees by role + Future fetchEmployeesByRole(String roleId) async { + isLoading.value = true; + final allocations = await ApiService.getServiceProjectAllocationList( + projectId: projectId.value); + + if (allocations != null) { + roleEmployees.assignAll( + allocations + .where((a) => a.teamRole.id == roleId) + .map((a) => a.employee) + .toList(), + ); + } + isLoading.value = false; + } + + void toggleEmployee(Employee emp) { + if (selectedEmployees.contains(emp)) { + selectedEmployees.remove(emp); + } else { + selectedEmployees.add(emp); + } + } + + Future submitAllocation() async { + final payload = selectedEmployees + .map((e) => { + "projectId": projectId.value, + "employeeId": e.id, + "teamRoleId": selectedRole.value?.id, + "isActive": true, + }) + .toList(); + + return await ApiService.manageServiceProjectAllocation(payload: payload); + } +} diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index 701bd43..c6b81bc 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -5,6 +5,7 @@ import 'package:marco/model/service_project/job_list_model.dart'; import 'package:marco/model/service_project/service_project_job_detail_model.dart'; import 'package:geolocator/geolocator.dart'; import 'package:marco/model/service_project/job_attendance_logs_model.dart'; +import 'package:marco/model/service_project/job_allocation_model.dart'; import 'dart:convert'; import 'dart:io'; @@ -35,6 +36,9 @@ class ServiceProjectDetailsController extends GetxController { var isTagging = false.obs; var attendanceMessage = ''.obs; var attendanceLog = Rxn(); + var teamList = [].obs; + var isTeamLoading = false.obs; + var teamErrorMessage = ''.obs; // -------------------- Lifecycle -------------------- @override @@ -52,6 +56,33 @@ class ServiceProjectDetailsController extends GetxController { fetchProjectJobs(initialLoad: true); } + Future fetchProjectTeams() async { + if (projectId.value.isEmpty) { + teamErrorMessage.value = "Invalid project ID"; + return; + } + + isTeamLoading.value = true; + teamErrorMessage.value = ''; + + try { + final result = await ApiService.getServiceProjectAllocationList( + projectId: projectId.value, + isActive: true, + ); + + if (result != null) { + teamList.value = result; + } else { + teamErrorMessage.value = "No teams found"; + } + } catch (e) { + teamErrorMessage.value = "Error fetching teams: $e"; + } finally { + isTeamLoading.value = false; + } + } + Future fetchProjectDetail() async { if (projectId.value.isEmpty) { errorMessage.value = "Invalid project ID"; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 5e1037f..8b5b277 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -146,4 +146,7 @@ class ApiEndpoints { static const String createServiceProjectJob = "/serviceproject/job/create"; static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance"; static const String serviceProjectUpateJobAttendanceLog = "/job/attendance/log"; + static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list"; + static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; + static const String getTeamRoles = "/master/team-roles/list"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index fe0e366..f3d7ce2 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -38,6 +38,7 @@ import 'package:marco/model/service_project/service_projects_details_model.dart' import 'package:marco/model/service_project/job_list_model.dart'; import 'package:marco/model/service_project/service_project_job_detail_model.dart'; import 'package:marco/model/service_project/job_attendance_logs_model.dart'; +import 'package:marco/model/service_project/job_allocation_model.dart'; class ApiService { static const bool enableLogs = true; @@ -95,8 +96,10 @@ class ApiService { 'Authorization': 'Bearer $token', }; - static void _log(String message) { - if (enableLogs) logSafe(message); + static void _log(String message, {LogLevel level = LogLevel.info}) { + if (enableLogs) { + logSafe(message, level: level); + } } static dynamic _parseResponse(http.Response response, {String label = ''}) { @@ -308,11 +311,108 @@ class ApiService { } // Service Project Module APIs - /// Fetch Job Attendance Log by ID + static Future?> getTeamRoles() async { + try { + final response = await _getRequest(ApiEndpoints.getTeamRoles); + + if (response == null) { + _log("getTeamRoles: No response received."); + return null; + } + + final parsedJson = _parseResponseForAllData(response, label: "TeamRoles"); + if (parsedJson == null) return null; + + // Map the 'data' array to List + final List dataList = parsedJson['data'] as List; + return dataList + .map((e) => TeamRole.fromJson(e as Map)) + .toList(); + } catch (e, stack) { + _log("Exception in getTeamRoles: $e\n$stack", level: LogLevel.error); + return null; + } + } + + /// Fetch Service Project Allocation List + + static Future?> + getServiceProjectAllocationList({ + required String projectId, + bool isActive = true, + }) async { + final queryParams = { + 'projectId': projectId, + 'isActive': isActive.toString(), + }; + + try { + final response = await _getRequest( + ApiEndpoints.getServiceProjectUpateJobAllocationList, + queryParams: queryParams, + ); + + if (response == null) { + _log("getServiceProjectAllocationList: No response received."); + return null; + } + + final parsedJson = _parseResponseForAllData(response, + label: "ServiceProjectAllocationList"); + if (parsedJson == null) return null; + + final dataList = (parsedJson['data'] as List) + .map((e) => ServiceProjectAllocation.fromJson(e)) + .toList(); + + return dataList; + } catch (e, stack) { + _log("Exception in getServiceProjectAllocationList: $e\n$stack"); + return null; + } + } + + /// Manage Service Project Allocation + static Future manageServiceProjectAllocation({ + required List> payload, + }) async { + try { + final response = await _postRequest( + ApiEndpoints.manageServiceProjectUpateJobAllocation, + payload, + ); + + if (response == null) { + _log("manageServiceProjectAllocation: No response received.", + level: LogLevel.error); + return false; + } + + final json = jsonDecode(response.body); + if (json['success'] == true) { + _log( + "Service Project Allocation updated successfully: ${json['data']}"); + return true; + } else { + _log( + "Failed to update Service Project Allocation: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + _log("Exception during manageServiceProjectAllocation: $e", + level: LogLevel.error); + _log("StackTrace: $stack", level: LogLevel.debug); + return false; + } + } + static Future getJobAttendanceLog({ required String attendanceId, }) async { - final endpoint = "${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId"; + final endpoint = + "${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId"; try { final response = await _getRequest(endpoint); diff --git a/lib/model/service_project/job_allocation_model.dart b/lib/model/service_project/job_allocation_model.dart new file mode 100644 index 0000000..bc29a63 --- /dev/null +++ b/lib/model/service_project/job_allocation_model.dart @@ -0,0 +1,203 @@ +class ServiceProjectAllocationResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ServiceProjectAllocationResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ServiceProjectAllocationResponse.fromJson(Map json) { + return ServiceProjectAllocationResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: (json['data'] as List) + .map((e) => ServiceProjectAllocation.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ServiceProjectAllocation { + final String id; + final Project project; + final Employee employee; + final TeamRole teamRole; + final bool isActive; + final DateTime assignedAt; + final Employee assignedBy; + final DateTime? reAssignedAt; + final Employee? reAssignedBy; + + ServiceProjectAllocation({ + required this.id, + required this.project, + required this.employee, + required this.teamRole, + required this.isActive, + required this.assignedAt, + required this.assignedBy, + this.reAssignedAt, + this.reAssignedBy, + }); + + factory ServiceProjectAllocation.fromJson(Map json) { + return ServiceProjectAllocation( + id: json['id'] as String, + project: Project.fromJson(json['project']), + employee: Employee.fromJson(json['employee']), + teamRole: TeamRole.fromJson(json['teamRole']), + isActive: json['isActive'] as bool, + assignedAt: DateTime.parse(json['assignedAt'] as String), + assignedBy: Employee.fromJson(json['assignedBy']), + reAssignedAt: json['reAssignedAt'] != null + ? DateTime.parse(json['reAssignedAt']) + : null, + reAssignedBy: json['reAssignedBy'] != null + ? Employee.fromJson(json['reAssignedBy']) + : null, + ); + } + + Map toJson() => { + 'id': id, + 'project': project.toJson(), + 'employee': employee.toJson(), + 'teamRole': teamRole.toJson(), + 'isActive': isActive, + 'assignedAt': assignedAt.toIso8601String(), + 'assignedBy': assignedBy.toJson(), + 'reAssignedAt': reAssignedAt?.toIso8601String(), + 'reAssignedBy': reAssignedBy?.toJson(), + }; +} + +class Project { + final String id; + final String name; + final String shortName; + final DateTime assignedDate; + final String contactName; + final String contactPhone; + final String contactEmail; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.assignedDate, + required this.contactName, + required this.contactPhone, + required this.contactEmail, + }); + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] as String, + name: json['name'] as String, + shortName: json['shortName'] as String, + assignedDate: DateTime.parse(json['assignedDate'] as String), + contactName: json['contactName'] as String, + contactPhone: json['contactPhone'] as String, + contactEmail: json['contactEmail'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'shortName': shortName, + 'assignedDate': assignedDate.toIso8601String(), + 'contactName': contactName, + 'contactPhone': contactPhone, + 'contactEmail': contactEmail, + }; +} + +class Employee { + final String id; + final String firstName; + final String lastName; + final String? email; + final String? photo; + final String jobRoleId; + final String jobRoleName; + + Employee({ + required this.id, + required this.firstName, + required this.lastName, + this.email, + this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory Employee.fromJson(Map json) { + return Employee( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String?, + photo: json['photo'] as String?, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; +} + +class TeamRole { + final String id; + final String name; + final String description; + + TeamRole({ + required this.id, + required this.name, + required this.description, + }); + + factory TeamRole.fromJson(Map json) { + return TeamRole( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/service_project/service_project_allocation_bottomsheet.dart b/lib/model/service_project/service_project_allocation_bottomsheet.dart new file mode 100644 index 0000000..7ea4614 --- /dev/null +++ b/lib/model/service_project/service_project_allocation_bottomsheet.dart @@ -0,0 +1,468 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/controller/service_project/service_project_allocation_controller.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; +import 'package:marco/model/service_project/job_allocation_model.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class RoleEmployeeAllocation { + final TeamRole role; + final List employees; + + RoleEmployeeAllocation({required this.role, required this.employees}); +} + +class SimpleProjectAllocationBottomSheet extends StatefulWidget { + final String projectId; + final List? existingAllocations; + + const SimpleProjectAllocationBottomSheet({ + super.key, + required this.projectId, + this.existingAllocations, + }); + + @override + State createState() => + _SimpleProjectAllocationBottomSheetState(); +} + +class _SimpleProjectAllocationBottomSheetState + extends State with UIMixin { + late ServiceProjectAllocationController controller; + + final RxList _selectedEmployees = [].obs; + final RxList _addedAllocations = + [].obs; + + final TextEditingController _employeeTextCtrl = TextEditingController(); + final _roleDropdownKey = GlobalKey(); + final RxBool _hasChanges = false.obs; + + bool _checkIfChanged() { + final existingMap = { + for (var alloc in widget.existingAllocations ?? []) + alloc.role.id: alloc.employees.map((e) => e.id).toSet() + }; + + final currentMap = { + for (var alloc in _addedAllocations) + alloc.role.id: alloc.employees.map((e) => e.id).toSet() + }; + + // Compare existing and current allocations + if (existingMap.length != currentMap.length) return true; + + for (var roleId in existingMap.keys) { + if (!currentMap.containsKey(roleId)) return true; + if (existingMap[roleId]!.difference(currentMap[roleId]!).isNotEmpty || + currentMap[roleId]!.difference(existingMap[roleId]!).isNotEmpty) { + return true; + } + } + + return false; + } + + @override + void initState() { + super.initState(); + controller = Get.put(ServiceProjectAllocationController()); + controller.projectId.value = widget.projectId; + controller.fetchRoles(); + + // Prepopulate existing allocations + if (widget.existingAllocations != null) { + _addedAllocations.assignAll(widget.existingAllocations!); + } + } + + @override + void dispose() { + _employeeTextCtrl.dispose(); + Get.delete(); + super.dispose(); + } + + InputDecoration _inputDecoration(String hint) => InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade400)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); + + Widget _roleDropdown() { + return GestureDetector( + key: _roleDropdownKey, + onTap: _showRoleMenu, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade400), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + offset: const Offset(0, 1), + blurRadius: 2, + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Obx(() => MyText.bodyMedium( + controller.selectedRole.value?.name ?? "Select Role")), + const Icon(Icons.arrow_drop_down_rounded, size: 30), + ], + ), + ), + ); + } + + Future _showRoleMenu() async { + if (_roleDropdownKey.currentContext == null) return; + + final RenderBox button = + _roleDropdownKey.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + + final selectedRoleId = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: controller.roles + .map( + (role) => PopupMenuItem( + value: role.id, + child: MyText.bodyMedium(role.name), + ), + ) + .toList(), + ); + + if (selectedRoleId != null) { + final role = controller.roles.firstWhere((r) => r.id == selectedRoleId); + controller.selectedRole.value = role; + controller.fetchEmployeesByRole(role.id); + + _selectedEmployees.clear(); + _employeeTextCtrl.clear(); + } + } + + Widget _employeeSelector() { + return GestureDetector( + onTap: () async { + final selected = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => EmployeeSelectionBottomSheet( + multipleSelection: true, + initiallySelected: _selectedEmployees, + title: "Select Employees", + ), + ); + if (selected != null) { + _selectedEmployees.assignAll(selected); + _employeeTextCtrl.text = _selectedEmployees + .map((e) => "${e.firstName} ${e.lastName}") + .join(", "); + } + }, + child: AbsorbPointer( + child: TextFormField( + controller: _employeeTextCtrl, + decoration: _inputDecoration("Select Employees").copyWith( + suffixIcon: const Icon(Icons.search), + ), + validator: (_) => + _selectedEmployees.isEmpty ? "Please select employees" : null, + ), + ), + ); + } + + void _handleAdd() { + final selectedRole = controller.selectedRole.value; + if (selectedRole == null || _selectedEmployees.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please select role and employees", + type: SnackbarType.error, + ); + return; + } + + final updatedAllocations = + List.from(_addedAllocations); + + final existingIndex = + updatedAllocations.indexWhere((a) => a.role.id == selectedRole.id); + + if (existingIndex >= 0) { + final existingAlloc = updatedAllocations[existingIndex]; + final mergedEmployees = [ + ...existingAlloc.employees, + ..._selectedEmployees.where( + (emp) => !existingAlloc.employees.any((e) => e.id == emp.id), + ), + ]; + updatedAllocations[existingIndex] = RoleEmployeeAllocation( + role: existingAlloc.role, employees: mergedEmployees); + } else { + updatedAllocations.add( + RoleEmployeeAllocation( + role: selectedRole, + employees: List.from(_selectedEmployees), + ), + ); + } + + _addedAllocations.assignAll(updatedAllocations); + _hasChanges.value = _checkIfChanged(); + controller.selectedRole.value = null; + _selectedEmployees.clear(); + _employeeTextCtrl.clear(); + } + + void _handleSubmit() async { + if (_addedAllocations.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please add at least one allocation", + type: SnackbarType.error, + ); + return; + } + + final payload = >[]; + + payload.addAll(_getRemovedEmployeesPayload()); + payload.addAll(_getAddedEmployeesPayload()); + + final success = + await ApiService.manageServiceProjectAllocation(payload: payload); + + if (success) { + Get.back(result: true); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to save allocation", + type: SnackbarType.error, + ); + } + } + + List> _getRemovedEmployeesPayload() { + final removedPayload = >[]; + + final existingMap = { + for (var alloc in widget.existingAllocations ?? []) + alloc.role.id: alloc.employees.map((e) => e.id).toList() + }; + + final currentMap = { + for (var alloc in _addedAllocations) + alloc.role.id: alloc.employees.map((e) => e.id).toList() + }; + + existingMap.forEach((roleId, existingEmpIds) { + final currentEmpIds = currentMap[roleId] ?? []; + final removedEmpIds = + existingEmpIds.where((id) => !currentEmpIds.contains(id)).toList(); + + for (var empId in removedEmpIds) { + removedPayload.add({ + "projectId": widget.projectId, + "employeeId": empId.toString(), + "teamRoleId": roleId, + "isActive": false, + }); + } + }); + + return removedPayload; + } + + List> _getAddedEmployeesPayload() { + final addedPayload = >[]; + + final existingMap = { + for (var alloc in widget.existingAllocations ?? []) + alloc.role.id: alloc.employees.map((e) => e.id).toList() + }; + + for (var alloc in _addedAllocations) { + final currentEmpIds = alloc.employees.map((e) => e.id).toList(); + final existingEmpIds = existingMap[alloc.role.id] ?? []; + + final newEmpIds = + currentEmpIds.where((id) => !existingEmpIds.contains(id)).toList(); + + for (var empId in newEmpIds) { + addedPayload.add({ + "projectId": widget.projectId, + "employeeId": empId.toString(), + "teamRoleId": alloc.role.id, + "isActive": true, + }); + } + } + + return addedPayload; + } + + Widget _addedAllocationList() { + return Obx(() { + if (_addedAllocations.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Added Allocations"), + const Divider(height: 22, thickness: 1.2), + ..._addedAllocations.map((alloc) => Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "Role: ${alloc.role.name}", + fontWeight: 700, + ), + const SizedBox(height: 7), + Wrap( + spacing: 7, + runSpacing: 5, + children: alloc.employees + .map((e) => Chip( + label: MyText.bodyMedium( + "${e.firstName} ${e.lastName}"), + onDeleted: () { + final updatedEmployees = alloc.employees + .where((emp) => emp.id != e.id) + .toList(); + + if (updatedEmployees.isEmpty) { + _addedAllocations.removeWhere( + (a) => a.role.id == alloc.role.id); + } else { + final updatedAlloc = RoleEmployeeAllocation( + role: alloc.role, + employees: updatedEmployees); + final index = _addedAllocations.indexWhere( + (a) => a.role.id == alloc.role.id); + _addedAllocations[index] = updatedAlloc; + } + + _addedAllocations.refresh(); + _hasChanges.value = _checkIfChanged(); + }, + backgroundColor: Colors.grey.shade200, + )) + .toList(), + ), + ], + ), + )), + ], + ); + }); + } + + @override + Widget build(BuildContext context) { + return Obx(() => BaseBottomSheet( + title: "Allocate Employees", + onCancel: () => Get.back(), + onSubmit: _hasChanges.value + ? _handleSubmit + : () { + showAppSnackbar( + title: "No changes", + message: "You haven't made any changes to submit.", + type: SnackbarType.info, + ); + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Select Role"), + MySpacing.height(8), + _roleDropdown(), + MySpacing.height(12), + MyText.labelMedium("Select Employees"), + MySpacing.height(8), + _employeeSelector(), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 7, + runSpacing: 5, + children: _selectedEmployees + .map((emp) => Chip( + label: MyText.bodyMedium( + "${emp.firstName} ${emp.lastName}"), + onDeleted: () { + _selectedEmployees.remove(emp); + _employeeTextCtrl.text = _selectedEmployees + .map( + (e) => "${e.firstName} ${e.lastName}") + .join(", "); + }, + )) + .toList(), + )), + MySpacing.height(12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.add_circle_outline), + label: MyText.bodyMedium( + "Add Allocation", + fontWeight: 600, + color: Colors.white, + ), + onPressed: _handleAdd, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: contentTheme.primary, + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + MySpacing.height(12), + _addedAllocationList(), + MySpacing.height(12), + ], + ), + ), + )); + } +} diff --git a/lib/model/service_project/team_roles_model.dart b/lib/model/service_project/team_roles_model.dart new file mode 100644 index 0000000..1056b7b --- /dev/null +++ b/lib/model/service_project/team_roles_model.dart @@ -0,0 +1,71 @@ +// team_roles_model.dart + +class TeamRolesResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + TeamRolesResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory TeamRolesResponse.fromJson(Map json) { + return TeamRolesResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: (json['data'] as List) + .map((e) => TeamRole.fromJson(e as Map)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class TeamRole { + final String id; + final String name; + final String description; + + TeamRole({ + required this.id, + required this.name, + required this.description, + }); + + factory TeamRole.fromJson(Map json) { + return TeamRole( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + }; + } +} diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index 67b56da..0b9911b 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -11,6 +11,8 @@ import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/view/service_project/service_project_job_detail_screen.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/model/service_project/service_project_allocation_bottomsheet.dart'; +import 'package:marco/model/employees/employee_model.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -33,7 +35,7 @@ class _ServiceProjectDetailsScreenState void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); controller = Get.put(ServiceProjectDetailsController()); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -42,9 +44,11 @@ class _ServiceProjectDetailsScreenState _tabController.addListener(() { if (!_tabController.indexIsChanging) { - setState(() {}); // rebuild to show/hide FAB + setState(() {}); if (_tabController.index == 1 && controller.jobList.isEmpty) { controller.fetchProjectJobs(); + } else if (_tabController.index == 2 && controller.teamList.isEmpty) { + controller.fetchProjectTeams(); } } }); @@ -315,8 +319,6 @@ class _ServiceProjectDetailsScreenState ); } - - Widget _buildJobsTab() { return Obx(() { if (controller.isJobLoading.value && controller.jobList.isEmpty) { @@ -351,7 +353,7 @@ class _ServiceProjectDetailsScreenState final job = controller.jobList[index]; return InkWell( onTap: () { - Get.to(() => JobDetailsScreen(jobId: job.id )); + Get.to(() => JobDetailsScreen(jobId: job.id)); }, child: Card( elevation: 3, @@ -406,8 +408,7 @@ class _ServiceProjectDetailsScreenState child: Avatar( firstName: assignee.firstName, lastName: assignee.lastName, - size: - 24, + size: 24, imageUrl: assignee.photo.isNotEmpty ? assignee.photo : null, @@ -469,6 +470,68 @@ class _ServiceProjectDetailsScreenState }); } + Widget _buildTeamsTab() { + return Obx(() { + if (controller.isTeamLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.teamErrorMessage.value.isNotEmpty && + controller.teamList.isEmpty) { + return Center( + child: MyText.bodyMedium(controller.teamErrorMessage.value)); + } + + if (controller.teamList.isEmpty) { + return Center(child: MyText.bodyMedium("No team members found")); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: controller.teamList.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final team = controller.teamList[index]; + return Card( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Avatar( + firstName: team.employee.firstName, + lastName: team.employee.lastName, + size: 32, + imageUrl: (team.employee.photo?.isNotEmpty ?? false) + ? team.employee.photo + : null, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + "${team.employee.firstName} ${team.employee.lastName}", + fontWeight: 700), + MyText.bodySmall(team.teamRole.name, + color: Colors.grey[700]), + MyText.bodySmall( + "Status: ${team.isActive ? 'Active' : 'Inactive'}", + color: Colors.grey[700]), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -493,6 +556,7 @@ class _ServiceProjectDetailsScreenState tabs: [ Tab(child: MyText.bodyMedium("Profile")), Tab(child: MyText.bodyMedium("Jobs")), + Tab(child: MyText.bodyMedium("Teams")), ], ), ), @@ -515,6 +579,7 @@ class _ServiceProjectDetailsScreenState children: [ _buildProfileTab(), _buildJobsTab(), + _buildTeamsTab(), ], ); }), @@ -538,7 +603,59 @@ class _ServiceProjectDetailsScreenState icon: const Icon(Icons.add), label: MyText.bodyMedium("Add Job", color: Colors.white), ) - : null, + : _tabController.index == 2 + ? FloatingActionButton.extended( + onPressed: () async { + // Prepare existing allocations grouped by role + Map> allocationsMap = {}; + for (var team in controller.teamList) { + if (!allocationsMap.containsKey(team.teamRole.id)) { + allocationsMap[team.teamRole.id] = []; + } + allocationsMap[team.teamRole.id]!.add(EmployeeModel( + id: team.employee.id, + jobRoleID: team.teamRole.id, + employeeId: team.employee.id, + name: + "${team.employee.firstName} ${team.employee.lastName}", + designation: team.teamRole.name, + firstName: team.employee.firstName, + lastName: team.employee.lastName, + activity: 0, + action: 0, + jobRole: team.teamRole.name, + email: team.employee.email ?? '', + phoneNumber: '', + )); + } + + final existingAllocations = + allocationsMap.entries.map((entry) { + final role = controller.teamList + .firstWhere((team) => team.teamRole.id == entry.key) + .teamRole; + return RoleEmployeeAllocation( + role: role, employees: entry.value); + }).toList(); + + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => SimpleProjectAllocationBottomSheet( + projectId: widget.projectId, + existingAllocations: existingAllocations, + ), + ); + + if (result == true) { + controller.fetchProjectTeams(); + } + }, + backgroundColor: contentTheme.primary, + icon: const Icon(Icons.group_add), + label: MyText.bodyMedium("Manage Team", color: Colors.white), + ) + : null, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); }