added manage team

This commit is contained in:
Vaibhav Surve 2025-11-18 17:44:12 +05:30
parent 17fc04f3ee
commit 253aa55a80
8 changed files with 1085 additions and 12 deletions

View File

@ -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 = <TeamRole>[].obs;
var selectedRole = Rxn<TeamRole>();
// Employees
var roleEmployees = <Employee>[].obs;
var selectedEmployees = <Employee>[].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<void> fetchRoles() async {
isLoading.value = true;
final result = await ApiService.getTeamRoles();
if (result != null) {
roles.assignAll(result);
}
isLoading.value = false;
}
// Fetch employees by role
Future<void> 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<bool> 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);
}
}

View File

@ -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:marco/model/service_project/service_project_job_detail_model.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:marco/model/service_project/job_attendance_logs_model.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:convert';
import 'dart:io'; import 'dart:io';
@ -35,6 +36,9 @@ class ServiceProjectDetailsController extends GetxController {
var isTagging = false.obs; var isTagging = false.obs;
var attendanceMessage = ''.obs; var attendanceMessage = ''.obs;
var attendanceLog = Rxn<JobAttendanceResponse>(); var attendanceLog = Rxn<JobAttendanceResponse>();
var teamList = <ServiceProjectAllocation>[].obs;
var isTeamLoading = false.obs;
var teamErrorMessage = ''.obs;
// -------------------- Lifecycle -------------------- // -------------------- Lifecycle --------------------
@override @override
@ -52,6 +56,33 @@ class ServiceProjectDetailsController extends GetxController {
fetchProjectJobs(initialLoad: true); fetchProjectJobs(initialLoad: true);
} }
Future<void> 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<void> fetchProjectDetail() async { Future<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) { if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID"; errorMessage.value = "Invalid project ID";

View File

@ -146,4 +146,7 @@ class ApiEndpoints {
static const String createServiceProjectJob = "/serviceproject/job/create"; static const String createServiceProjectJob = "/serviceproject/job/create";
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance"; static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
static const String serviceProjectUpateJobAttendanceLog = "/job/attendance/log"; 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";
} }

View File

@ -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/job_list_model.dart';
import 'package:marco/model/service_project/service_project_job_detail_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_attendance_logs_model.dart';
import 'package:marco/model/service_project/job_allocation_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -95,8 +96,10 @@ class ApiService {
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
}; };
static void _log(String message) { static void _log(String message, {LogLevel level = LogLevel.info}) {
if (enableLogs) logSafe(message); if (enableLogs) {
logSafe(message, level: level);
}
} }
static dynamic _parseResponse(http.Response response, {String label = ''}) { static dynamic _parseResponse(http.Response response, {String label = ''}) {
@ -308,11 +311,108 @@ class ApiService {
} }
// Service Project Module APIs // Service Project Module APIs
/// Fetch Job Attendance Log by ID static Future<List<TeamRole>?> 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<TeamRole>
final List<dynamic> dataList = parsedJson['data'] as List<dynamic>;
return dataList
.map((e) => TeamRole.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e, stack) {
_log("Exception in getTeamRoles: $e\n$stack", level: LogLevel.error);
return null;
}
}
/// Fetch Service Project Allocation List
static Future<List<ServiceProjectAllocation>?>
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<dynamic>)
.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<bool> manageServiceProjectAllocation({
required List<Map<String, dynamic>> 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<JobAttendanceResponse?> getJobAttendanceLog({ static Future<JobAttendanceResponse?> getJobAttendanceLog({
required String attendanceId, required String attendanceId,
}) async { }) async {
final endpoint = "${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId"; final endpoint =
"${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId";
try { try {
final response = await _getRequest(endpoint); final response = await _getRequest(endpoint);

View File

@ -0,0 +1,203 @@
class ServiceProjectAllocationResponse {
final bool success;
final String message;
final List<ServiceProjectAllocation> 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<String, dynamic> json) {
return ServiceProjectAllocationResponse(
success: json['success'] as bool,
message: json['message'] as String,
data: (json['data'] as List<dynamic>)
.map((e) => ServiceProjectAllocation.fromJson(e))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return TeamRole(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'description': description,
};
}

View File

@ -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<EmployeeModel> employees;
RoleEmployeeAllocation({required this.role, required this.employees});
}
class SimpleProjectAllocationBottomSheet extends StatefulWidget {
final String projectId;
final List<RoleEmployeeAllocation>? existingAllocations;
const SimpleProjectAllocationBottomSheet({
super.key,
required this.projectId,
this.existingAllocations,
});
@override
State<SimpleProjectAllocationBottomSheet> createState() =>
_SimpleProjectAllocationBottomSheetState();
}
class _SimpleProjectAllocationBottomSheetState
extends State<SimpleProjectAllocationBottomSheet> with UIMixin {
late ServiceProjectAllocationController controller;
final RxList<EmployeeModel> _selectedEmployees = <EmployeeModel>[].obs;
final RxList<RoleEmployeeAllocation> _addedAllocations =
<RoleEmployeeAllocation>[].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<ServiceProjectAllocationController>();
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<void> _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<String>(
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<String>(
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<List<EmployeeModel>>(
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<RoleEmployeeAllocation>.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 = <Map<String, dynamic>>[];
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<Map<String, dynamic>> _getRemovedEmployeesPayload() {
final removedPayload = <Map<String, dynamic>>[];
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<Map<String, dynamic>> _getAddedEmployeesPayload() {
final addedPayload = <Map<String, dynamic>>[];
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),
],
),
),
));
}
}

View File

@ -0,0 +1,71 @@
// team_roles_model.dart
class TeamRolesResponse {
final bool success;
final String message;
final List<TeamRole> 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<String, dynamic> json) {
return TeamRolesResponse(
success: json['success'] as bool,
message: json['message'] as String,
data: (json['data'] as List<dynamic>)
.map((e) => TeamRole.fromJson(e as Map<String, dynamic>))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return TeamRole(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
};
}
}

View File

@ -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/view/service_project/service_project_job_detail_screen.dart';
import 'package:marco/helpers/widgets/custom_app_bar.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/avatar.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 { class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId; final String projectId;
@ -33,7 +35,7 @@ class _ServiceProjectDetailsScreenState
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 3, vsync: this);
controller = Get.put(ServiceProjectDetailsController()); controller = Get.put(ServiceProjectDetailsController());
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -42,9 +44,11 @@ class _ServiceProjectDetailsScreenState
_tabController.addListener(() { _tabController.addListener(() {
if (!_tabController.indexIsChanging) { if (!_tabController.indexIsChanging) {
setState(() {}); // rebuild to show/hide FAB setState(() {});
if (_tabController.index == 1 && controller.jobList.isEmpty) { if (_tabController.index == 1 && controller.jobList.isEmpty) {
controller.fetchProjectJobs(); controller.fetchProjectJobs();
} else if (_tabController.index == 2 && controller.teamList.isEmpty) {
controller.fetchProjectTeams();
} }
} }
}); });
@ -315,8 +319,6 @@ class _ServiceProjectDetailsScreenState
); );
} }
Widget _buildJobsTab() { Widget _buildJobsTab() {
return Obx(() { return Obx(() {
if (controller.isJobLoading.value && controller.jobList.isEmpty) { if (controller.isJobLoading.value && controller.jobList.isEmpty) {
@ -351,7 +353,7 @@ class _ServiceProjectDetailsScreenState
final job = controller.jobList[index]; final job = controller.jobList[index];
return InkWell( return InkWell(
onTap: () { onTap: () {
Get.to(() => JobDetailsScreen(jobId: job.id )); Get.to(() => JobDetailsScreen(jobId: job.id));
}, },
child: Card( child: Card(
elevation: 3, elevation: 3,
@ -406,8 +408,7 @@ class _ServiceProjectDetailsScreenState
child: Avatar( child: Avatar(
firstName: assignee.firstName, firstName: assignee.firstName,
lastName: assignee.lastName, lastName: assignee.lastName,
size: size: 24,
24,
imageUrl: assignee.photo.isNotEmpty imageUrl: assignee.photo.isNotEmpty
? assignee.photo ? assignee.photo
: null, : 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -493,6 +556,7 @@ class _ServiceProjectDetailsScreenState
tabs: [ tabs: [
Tab(child: MyText.bodyMedium("Profile")), Tab(child: MyText.bodyMedium("Profile")),
Tab(child: MyText.bodyMedium("Jobs")), Tab(child: MyText.bodyMedium("Jobs")),
Tab(child: MyText.bodyMedium("Teams")),
], ],
), ),
), ),
@ -515,6 +579,7 @@ class _ServiceProjectDetailsScreenState
children: [ children: [
_buildProfileTab(), _buildProfileTab(),
_buildJobsTab(), _buildJobsTab(),
_buildTeamsTab(),
], ],
); );
}), }),
@ -538,6 +603,58 @@ class _ServiceProjectDetailsScreenState
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: MyText.bodyMedium("Add Job", color: Colors.white), label: MyText.bodyMedium("Add Job", color: Colors.white),
) )
: _tabController.index == 2
? FloatingActionButton.extended(
onPressed: () async {
// Prepare existing allocations grouped by role
Map<String, List<EmployeeModel>> 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<bool>(
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, : null,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
); );