469 lines
16 KiB
Dart
469 lines
16 KiB
Dart
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),
|
|
],
|
|
),
|
|
),
|
|
));
|
|
}
|
|
}
|