637 lines
20 KiB
Dart
637 lines
20 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/theme/app_theme.dart';
|
|
import 'package:marco/controller/employee/employees_screen_controller.dart';
|
|
import 'package:marco/model/employees/employee_model.dart';
|
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
|
import 'package:marco/helpers/services/api_service.dart';
|
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
|
|
class ManageReportingBottomSheet extends StatefulWidget {
|
|
final EmployeeModel? initialEmployee;
|
|
final bool hideMainSelector;
|
|
final bool renderAsCard;
|
|
final bool hideLoggedUserFromSelection;
|
|
final String? loggedUserId;
|
|
|
|
const ManageReportingBottomSheet({
|
|
super.key,
|
|
this.initialEmployee,
|
|
this.hideMainSelector = false,
|
|
this.renderAsCard = false,
|
|
this.hideLoggedUserFromSelection = false,
|
|
this.loggedUserId,
|
|
});
|
|
|
|
@override
|
|
State<ManageReportingBottomSheet> createState() =>
|
|
_ManageReportingBottomSheetState();
|
|
}
|
|
|
|
class _ManageReportingBottomSheetState
|
|
extends State<ManageReportingBottomSheet> {
|
|
final EmployeesScreenController _employeeController = Get.find();
|
|
final TextEditingController _primaryController = TextEditingController();
|
|
final TextEditingController _secondaryController = TextEditingController();
|
|
|
|
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
|
|
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
|
|
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
|
|
final RxList<EmployeeModel> _selectedSecondary = <EmployeeModel>[].obs;
|
|
|
|
final TextEditingController _selectEmployeeController =
|
|
TextEditingController();
|
|
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
|
EmployeeModel? _selectedEmployee;
|
|
|
|
bool _isSubmitting = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_primaryController
|
|
.addListener(() => _filterEmployees(_primaryController.text, true));
|
|
_secondaryController
|
|
.addListener(() => _filterEmployees(_secondaryController.text, false));
|
|
_selectEmployeeController
|
|
.addListener(() => _filterMainEmployee(_selectEmployeeController.text));
|
|
|
|
// if the parent passed an initialEmployee (profile page), preselect & load hierarchy
|
|
if (widget.initialEmployee != null) {
|
|
// delay to let widget finish first build
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_onMainEmployeeSelected(widget.initialEmployee!);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_primaryController.dispose();
|
|
_secondaryController.dispose();
|
|
_selectEmployeeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _filterMainEmployee(String query) {
|
|
final employees = _employeeController.employees;
|
|
final searchQuery = query.toLowerCase();
|
|
|
|
final filtered = query.isEmpty
|
|
? <EmployeeModel>[]
|
|
: employees
|
|
.where((e) => e.name.toLowerCase().contains(searchQuery))
|
|
.take(6)
|
|
.toList();
|
|
|
|
_filteredEmployees.assignAll(filtered);
|
|
}
|
|
|
|
void _filterEmployees(String query, bool isPrimary) {
|
|
final employees = _employeeController.employees;
|
|
final searchQuery = query.toLowerCase();
|
|
|
|
final filtered = query.isEmpty
|
|
? <EmployeeModel>[]
|
|
: employees
|
|
.where((e) => e.name.toLowerCase().contains(searchQuery))
|
|
.take(6)
|
|
.toList();
|
|
|
|
if (isPrimary) {
|
|
_filteredPrimary.assignAll(filtered);
|
|
} else {
|
|
_filteredSecondary.assignAll(filtered);
|
|
}
|
|
}
|
|
|
|
void _toggleSelection(EmployeeModel emp, bool isPrimary) {
|
|
final list = isPrimary ? _selectedPrimary : _selectedSecondary;
|
|
|
|
if (isPrimary) {
|
|
//Allow only one primary employee at a time
|
|
list.clear();
|
|
list.add(emp);
|
|
} else {
|
|
// ✅ Secondary employees can still have multiple selections
|
|
if (list.any((e) => e.id == emp.id)) {
|
|
list.removeWhere((e) => e.id == emp.id);
|
|
} else {
|
|
list.add(emp);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// helper to find employee by id from controller list (returns nullable)
|
|
EmployeeModel? _findEmployeeById(String id) {
|
|
for (final e in _employeeController.employees) {
|
|
if (e.id == id) return e;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Called when user taps an employee from dropdown to manage reporting for.
|
|
/// It sets selected employee and fetches existing hierarchy to preselect reporters.
|
|
Future<void> _onMainEmployeeSelected(EmployeeModel emp) async {
|
|
setState(() {
|
|
_selectedEmployee = emp;
|
|
_selectEmployeeController.text = emp.name;
|
|
_filteredEmployees.clear();
|
|
});
|
|
|
|
// Clear previous selections
|
|
_selectedPrimary.clear();
|
|
_selectedSecondary.clear();
|
|
|
|
// Fetch existing reporting hierarchy for this employee
|
|
try {
|
|
final data = await ApiService.getOrganizationHierarchyList(emp.id);
|
|
if (data == null || data.isEmpty) return;
|
|
|
|
for (final item in data) {
|
|
try {
|
|
final isPrimary = item['isPrimary'] == true;
|
|
final reportTo = item['reportTo'];
|
|
if (reportTo == null) continue;
|
|
final reportToId = reportTo['id'] as String?;
|
|
if (reportToId == null) continue;
|
|
|
|
final match = _findEmployeeById(reportToId);
|
|
if (match == null) continue;
|
|
|
|
// ✅ Skip the employee whose profile is open
|
|
if (widget.initialEmployee != null &&
|
|
match.id == widget.initialEmployee!.id) {
|
|
continue;
|
|
}
|
|
|
|
if (isPrimary) {
|
|
if (!_selectedPrimary.any((e) => e.id == match.id)) {
|
|
_selectedPrimary.add(match);
|
|
}
|
|
} else {
|
|
if (!_selectedSecondary.any((e) => e.id == match.id)) {
|
|
_selectedSecondary.add(match);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// ignore malformed items
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Fetch failure - show a subtle snackbar
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Failed to load existing reporting.',
|
|
type: SnackbarType.error);
|
|
}
|
|
}
|
|
|
|
void _resetForm() {
|
|
setState(() {
|
|
_selectedEmployee = null;
|
|
_selectEmployeeController.clear();
|
|
_selectedPrimary.clear();
|
|
_selectedSecondary.clear();
|
|
_filteredEmployees.clear();
|
|
_filteredPrimary.clear();
|
|
_filteredSecondary.clear();
|
|
});
|
|
}
|
|
|
|
void _resetReportersOnly() {
|
|
_selectedPrimary.clear();
|
|
_selectedSecondary.clear();
|
|
_primaryController.clear();
|
|
_secondaryController.clear();
|
|
_filteredPrimary.clear();
|
|
_filteredSecondary.clear();
|
|
}
|
|
|
|
Future<void> _handleSubmit() async {
|
|
if (_selectedEmployee == null) {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Please select the employee.',
|
|
type: SnackbarType.error,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_selectedPrimary.isEmpty) {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Please select at least one primary employee.',
|
|
type: SnackbarType.error,
|
|
);
|
|
return;
|
|
}
|
|
|
|
final employeeId = _selectedEmployee!.id;
|
|
|
|
// === BUILD PAYLOAD (updated logic) ===
|
|
|
|
// fetch current assignments so we can deactivate old ones
|
|
List<dynamic>? currentAssignments;
|
|
try {
|
|
currentAssignments =
|
|
await ApiService.getOrganizationHierarchyList(employeeId);
|
|
} catch (_) {
|
|
currentAssignments = null;
|
|
}
|
|
|
|
// helper sets of newly selected ids
|
|
final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet();
|
|
final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet();
|
|
|
|
final List<Map<String, dynamic>> payload = [];
|
|
|
|
// 1) For current active assignments: if they are not in new selections -> add isActive:false
|
|
if (currentAssignments != null && currentAssignments.isNotEmpty) {
|
|
for (final item in currentAssignments) {
|
|
try {
|
|
final reportTo = item['reportTo'];
|
|
if (reportTo == null) continue;
|
|
final reportToId = reportTo['id'] as String?;
|
|
if (reportToId == null) continue;
|
|
final isPrimary = item['isPrimary'] == true;
|
|
final currentlyActive =
|
|
item['isActive'] == true || item['isActive'] == null; // be safe
|
|
|
|
// if currently active and not included in new selection -> mark false
|
|
if (currentlyActive) {
|
|
if (isPrimary && !newPrimaryIds.contains(reportToId)) {
|
|
payload.add({
|
|
"reportToId": reportToId,
|
|
"isPrimary": true,
|
|
"isActive": false,
|
|
});
|
|
} else if (!isPrimary && !newSecondaryIds.contains(reportToId)) {
|
|
payload.add({
|
|
"reportToId": reportToId,
|
|
"isPrimary": false,
|
|
"isActive": false,
|
|
});
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// ignore malformed items (same behavior as before)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) Add new primary (active)
|
|
for (final emp in _selectedPrimary) {
|
|
payload.add({
|
|
"reportToId": emp.id,
|
|
"isPrimary": true,
|
|
"isActive": true,
|
|
});
|
|
}
|
|
|
|
// 3) Add new secondary (active)
|
|
for (final emp in _selectedSecondary) {
|
|
payload.add({
|
|
"reportToId": emp.id,
|
|
"isPrimary": false,
|
|
"isActive": true,
|
|
});
|
|
}
|
|
|
|
setState(() => _isSubmitting = true);
|
|
|
|
// show loader
|
|
Get.dialog(
|
|
const Center(child: CircularProgressIndicator()),
|
|
barrierDismissible: false,
|
|
);
|
|
|
|
final success = await ApiService.manageOrganizationHierarchy(
|
|
employeeId: employeeId,
|
|
payload: payload,
|
|
);
|
|
|
|
// hide loader
|
|
if (Get.isDialogOpen == true) Get.back();
|
|
setState(() => _isSubmitting = false);
|
|
|
|
if (success) {
|
|
showAppSnackbar(
|
|
title: 'Success',
|
|
message: 'Reporting assigned successfully',
|
|
type: SnackbarType.success,
|
|
);
|
|
|
|
// ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers
|
|
try {
|
|
final empId = employeeId;
|
|
final EmployeesScreenController controller = Get.find();
|
|
await controller.fetchReportingManagers(empId);
|
|
await controller.fetchEmployeeDetails(empId);
|
|
} catch (_) {
|
|
|
|
}
|
|
|
|
// Optional: re-fetch the organization hierarchy list (if needed elsewhere)
|
|
await ApiService.getOrganizationHierarchyList(employeeId);
|
|
|
|
|
|
_resetForm();
|
|
if (Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
} else {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Failed to assign reporting. Please try again.',
|
|
type: SnackbarType.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleCancel() => Navigator.pop(context);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// build the same child column content you already had, but assign to a variable
|
|
final Widget content = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// conditional: show search section or simple header
|
|
if (widget.hideMainSelector)
|
|
_buildMainEmployeeHeader()
|
|
else
|
|
_buildMainEmployeeSection(),
|
|
|
|
MySpacing.height(20),
|
|
|
|
// Primary Employees section
|
|
_buildSearchSection(
|
|
label: "Primary Reporting Manager*",
|
|
controller: _primaryController,
|
|
filteredList: _filteredPrimary,
|
|
selectedList: _selectedPrimary,
|
|
isPrimary: true,
|
|
),
|
|
|
|
MySpacing.height(20),
|
|
|
|
// Secondary Employees section
|
|
_buildSearchSection(
|
|
label: "Secondary Reporting Manager",
|
|
controller: _secondaryController,
|
|
filteredList: _filteredSecondary,
|
|
selectedList: _selectedSecondary,
|
|
isPrimary: false,
|
|
),
|
|
],
|
|
);
|
|
|
|
if (widget.renderAsCard) {
|
|
// Inline card for profile screen
|
|
return Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
elevation: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: content,
|
|
),
|
|
);
|
|
}
|
|
|
|
// default: existing bottom sheet usage
|
|
return BaseBottomSheet(
|
|
title: "Manage Reporting",
|
|
submitText: "Submit",
|
|
isSubmitting: _isSubmitting,
|
|
onCancel: _handleCancel,
|
|
onSubmit: _handleSubmit,
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
Widget _buildMainEmployeeHeader() {
|
|
// show selected employee name non-editable (chip-style)
|
|
final emp = _selectedEmployee;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium("Selected Employee", fontWeight: 600),
|
|
MySpacing.height(8),
|
|
if (emp != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Chip(
|
|
label: Text(emp.name, style: const TextStyle(fontSize: 12)),
|
|
backgroundColor: Colors.indigo.shade50,
|
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
|
deleteIcon: const Icon(Icons.close, size: 16),
|
|
),
|
|
)
|
|
else
|
|
const Text('No employee selected',
|
|
style: TextStyle(color: Colors.grey)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchSection({
|
|
required String label,
|
|
required TextEditingController controller,
|
|
required RxList<EmployeeModel> filteredList,
|
|
required RxList<EmployeeModel> selectedList,
|
|
required bool isPrimary,
|
|
}) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium(label, fontWeight: 600),
|
|
MySpacing.height(8),
|
|
|
|
// Search field
|
|
TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
hintText: "Type to search employees...",
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: Colors.grey[50],
|
|
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Dropdown suggestions
|
|
Obx(() {
|
|
if (filteredList.isEmpty) return const SizedBox.shrink();
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(6),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
)
|
|
],
|
|
),
|
|
child: ListView.builder(
|
|
itemCount: filteredList.length,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemBuilder: (_, index) {
|
|
final emp = filteredList[index];
|
|
final isSelected = selectedList.any((e) => e.id == emp.id);
|
|
return ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
|
child: MyText.labelSmall(
|
|
emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?",
|
|
fontWeight: 600,
|
|
color: AppTheme.primaryColor,
|
|
),
|
|
),
|
|
title: Text(emp.name, style: const TextStyle(fontSize: 13)),
|
|
trailing: Icon(
|
|
isSelected
|
|
? Icons.check_circle
|
|
: Icons.radio_button_unchecked,
|
|
color: isSelected ? AppTheme.primaryColor : Colors.grey,
|
|
size: 18,
|
|
),
|
|
onTap: () {
|
|
_toggleSelection(emp, isPrimary);
|
|
filteredList.clear();
|
|
controller.clear();
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
|
|
MySpacing.height(10),
|
|
|
|
// Selected employees as chips
|
|
Obx(() {
|
|
if (selectedList.isEmpty) return const SizedBox.shrink();
|
|
return Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: selectedList.map((emp) {
|
|
final isProfileEmployee = widget.initialEmployee != null &&
|
|
emp.id == widget.initialEmployee!.id;
|
|
|
|
return Chip(
|
|
label: Text(emp.name, style: const TextStyle(fontSize: 12)),
|
|
backgroundColor: isProfileEmployee
|
|
? Colors.indigo.shade50
|
|
: Colors.indigo.shade50,
|
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
|
// Only show delete icon / action for non-profile employees
|
|
deleteIcon: isProfileEmployee
|
|
? null
|
|
: const Icon(Icons.close, size: 16),
|
|
onDeleted:
|
|
isProfileEmployee ? null : () => selectedList.remove(emp),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMainEmployeeSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium("Select Employee *", fontWeight: 600),
|
|
MySpacing.height(8),
|
|
TextField(
|
|
controller: _selectEmployeeController,
|
|
decoration: InputDecoration(
|
|
hintText: "Type to search employee...",
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: Colors.grey[50],
|
|
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
),
|
|
Obx(() {
|
|
if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(6),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
)
|
|
],
|
|
),
|
|
child: ListView.builder(
|
|
itemCount: _filteredEmployees.length,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemBuilder: (_, index) {
|
|
final emp = _filteredEmployees[index];
|
|
return ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
|
child: MyText.labelSmall(
|
|
emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?",
|
|
fontWeight: 600,
|
|
color: AppTheme.primaryColor,
|
|
),
|
|
),
|
|
title: Text(emp.name, style: const TextStyle(fontSize: 13)),
|
|
onTap: () => _onMainEmployeeSelected(emp),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
if (_selectedEmployee != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Chip(
|
|
label: Text(_selectedEmployee!.name,
|
|
style: const TextStyle(fontSize: 12)),
|
|
backgroundColor: Colors.indigo.shade50,
|
|
labelStyle: TextStyle(color: AppTheme.primaryColor),
|
|
deleteIcon: const Icon(Icons.close, size: 16),
|
|
onDeleted: () => setState(() {
|
|
_selectedEmployee = null;
|
|
_selectEmployeeController.clear();
|
|
// clear selected reporters too, since employee changed
|
|
_resetReportersOnly();
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|