marco.pms.mobileapp/lib/view/employees/manage_reporting_bottom_sheet.dart

634 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 (_) {
// ignore if controller not found — not critical
}
// Optional: re-fetch the organization hierarchy list (if needed elsewhere)
await ApiService.getOrganizationHierarchyList(employeeId);
// Keep sheet open and reset selections
_resetForm();
} 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();
}),
),
),
],
);
}
}