added manage team
This commit is contained in:
parent
17fc04f3ee
commit
253aa55a80
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<JobAttendanceResponse>();
|
||||
var teamList = <ServiceProjectAllocation>[].obs;
|
||||
var isTeamLoading = false.obs;
|
||||
var teamErrorMessage = ''.obs;
|
||||
|
||||
// -------------------- Lifecycle --------------------
|
||||
@override
|
||||
@ -52,6 +56,33 @@ class ServiceProjectDetailsController extends GetxController {
|
||||
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 {
|
||||
if (projectId.value.isEmpty) {
|
||||
errorMessage.value = "Invalid project ID";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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<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({
|
||||
required String attendanceId,
|
||||
}) async {
|
||||
final endpoint = "${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId";
|
||||
final endpoint =
|
||||
"${ApiEndpoints.serviceProjectUpateJobAttendanceLog}/$attendanceId";
|
||||
|
||||
try {
|
||||
final response = await _getRequest(endpoint);
|
||||
|
||||
203
lib/model/service_project/job_allocation_model.dart
Normal file
203
lib/model/service_project/job_allocation_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
71
lib/model/service_project/team_roles_model.dart
Normal file
71
lib/model/service_project/team_roles_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,6 +603,58 @@ class _ServiceProjectDetailsScreenState
|
||||
icon: const Icon(Icons.add),
|
||||
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,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user