implementation of Manage reporting

This commit is contained in:
Manish 2025-11-12 12:04:35 +05:30
parent 6b56351a49
commit 340d0b8a1e
7 changed files with 811 additions and 46 deletions

View File

@ -1,7 +1,7 @@
class ApiEndpoints { class ApiEndpoints {
static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://stageapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; static const String baseUrl = "https://devapi.marcoaiot.com/api";
static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories = static const String getMasterExpensesCategories =
@ -127,4 +127,10 @@ class ApiEndpoints {
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
static const String getAdvancePayments = '/Expense/get/transactions'; static const String getAdvancePayments = '/Expense/get/transactions';
// Organization Hierarchy endpoints
static const String getOrganizationHierarchyList =
"/organization/hierarchy/list";
static const String manageOrganizationHierarchy =
"/organization/hierarchy/manage";
} }

View File

@ -655,6 +655,60 @@ class ApiService {
} }
} }
/// Fetch hierarchy list for an employee
static Future<List<dynamic>?> getOrganizationHierarchyList(
String employeeId) async {
if (employeeId.isEmpty) throw ArgumentError('employeeId must not be empty');
final endpoint = "${ApiEndpoints.getOrganizationHierarchyList}/$employeeId";
return _getRequest(endpoint).then(
(res) => res != null
? _parseResponse(res, label: 'Organization Hierarchy List')
: null,
);
}
/// Manage (create/update) organization hierarchy (assign reporters) for an employee
/// payload is a List<Map<String,dynamic>> with objects like:
/// { "reportToId": "<uuid>", "isPrimary": true, "isActive": true }
static Future<bool> manageOrganizationHierarchy({
required String employeeId,
required List<Map<String, dynamic>> payload,
}) async {
if (employeeId.isEmpty) throw ArgumentError('employeeId must not be empty');
final endpoint = "${ApiEndpoints.manageOrganizationHierarchy}/$employeeId";
logSafe("manageOrganizationHierarchy for $employeeId payload: $payload");
try {
final response = await _postRequest(endpoint, payload);
if (response == null) {
logSafe("Manage hierarchy failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Manage hierarchy response status: ${response.statusCode}");
logSafe("Manage hierarchy response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Manage hierarchy succeeded");
return true;
}
logSafe("Manage hierarchy failed: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.error);
return false;
} catch (e, stack) {
logSafe("Exception while manageOrganizationHierarchy: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Master Currencies /// Get Master Currencies
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async { static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
const endpoint = ApiEndpoints.getMasterCurrencies; const endpoint = ApiEndpoints.getMasterCurrencies;

View File

@ -23,6 +23,8 @@ class EmployeeDetailsModel {
final String? organizationId; final String? organizationId;
final String? aadharNumber; final String? aadharNumber;
final String? panNumber; final String? panNumber;
EmployeeDetailsModel({ EmployeeDetailsModel({
required this.id, required this.id,
required this.firstName, required this.firstName,

View File

@ -11,6 +11,8 @@ import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart'; import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/view/employees/assign_employee_bottom_sheet.dart'; import 'package:marco/view/employees/assign_employee_bottom_sheet.dart';
class EmployeeDetailPage extends StatefulWidget { class EmployeeDetailPage extends StatefulWidget {
@ -283,6 +285,64 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
), ),
MySpacing.height(16), MySpacing.height(16),
_buildSectionCard(
title: 'Manage Reporting',
titleIcon: Icons.people_outline,
children: [
GestureDetector(
onTap: () async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => ManageReportingBottomSheet(
initialEmployee: EmployeeModel(
id: employee.id,
employeeId: employee.id.toString(),
firstName: employee.firstName ?? "",
lastName: employee.lastName ?? "",
name:
"${employee.firstName} ${employee.lastName}",
email: employee.email ?? "",
jobRole: employee.jobRole ?? "",
jobRoleID: "0",
designation: employee.jobRole ?? "",
phoneNumber: employee.phoneNumber ?? "",
activity: 0,
action: 0,
),
hideMainSelector: true,
hideLoggedUserFromSelection:
true,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
const Icon(Icons.manage_accounts_outlined,
color: Colors.grey),
const SizedBox(width: 16),
const Expanded(
child: Text(
'View / Update Reporting Managers',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
),
const Icon(Icons.arrow_forward_ios_rounded,
size: 16, color: Colors.grey),
],
),
),
),
],
),
// Contact Information Section // Contact Information Section
_buildSectionCard( _buildSectionCard(
title: 'Contact Information', title: 'Contact Information',

View File

@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
import 'package:marco/view/employees/employee_profile_screen.dart'; import 'package:marco/view/employees/employee_profile_screen.dart';
import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -64,13 +65,17 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
final searchQuery = query.toLowerCase(); final searchQuery = query.toLowerCase();
final filtered = query.isEmpty final filtered = query.isEmpty
? List<EmployeeModel>.from(employees) ? List<EmployeeModel>.from(employees)
: employees.where((e) => : employees
e.name.toLowerCase().contains(searchQuery) || .where(
e.email.toLowerCase().contains(searchQuery) || (e) =>
e.phoneNumber.toLowerCase().contains(searchQuery) || e.name.toLowerCase().contains(searchQuery) ||
e.jobRole.toLowerCase().contains(searchQuery), e.email.toLowerCase().contains(searchQuery) ||
).toList(); e.phoneNumber.toLowerCase().contains(searchQuery) ||
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); e.jobRole.toLowerCase().contains(searchQuery),
)
.toList();
filtered
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_filteredEmployees.assignAll(filtered); _filteredEmployees.assignAll(filtered);
} }
@ -106,7 +111,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
await _refreshEmployees(); await _refreshEmployees();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -160,7 +164,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'), onPressed: () => Get.offNamed('/dashboard'),
), ),
MySpacing.width(8), MySpacing.width(8),
@ -206,7 +211,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Widget _buildFloatingActionButton() { Widget _buildFloatingActionButton() {
return Obx(() { return Obx(() {
if (_permissionController.isLoading.value) return const SizedBox.shrink(); if (_permissionController.isLoading.value) return const SizedBox.shrink();
final hasPermission = _permissionController.hasPermission(Permissions.manageEmployees); final hasPermission =
_permissionController.hasPermission(Permissions.manageEmployees);
if (!hasPermission) return const SizedBox.shrink(); if (!hasPermission) return const SizedBox.shrink();
return InkWell( return InkWell(
@ -218,7 +224,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
color: contentTheme.primary, color: contentTheme.primary,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
boxShadow: const [ boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)), BoxShadow(
color: Colors.black26, blurRadius: 6, offset: Offset(0, 3)),
], ],
), ),
child: const Row( child: const Row(
@ -235,33 +242,116 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildSearchField() { Widget _buildSearchField() {
return SizedBox( return Padding(
height: 36, padding: MySpacing.xy(8, 8),
child: TextField( child: Row(
controller: _searchController, children: [
style: const TextStyle(fontSize: 13, height: 1.2), // Search field
decoration: InputDecoration( Expanded(
isDense: true, child: SizedBox(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), height: 35,
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), child: TextField(
hintText: 'Search employees...', controller: _searchController,
filled: true, style: const TextStyle(fontSize: 13, height: 1.2),
fillColor: Colors.white, decoration: InputDecoration(
border: OutlineInputBorder( contentPadding: const EdgeInsets.symmetric(horizontal: 12),
borderRadius: BorderRadius.circular(8), prefixIcon:
borderSide: BorderSide(color: Colors.grey.shade300, width: 1), const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton(
icon: const Icon(Icons.clear,
size: 20, color: Colors.grey),
onPressed: () {
_searchController.clear();
_filterEmployees('');
},
);
},
),
hintText: 'Search employees...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
onChanged: (_) => _filterEmployees(_searchController.text),
),
),
), ),
suffixIcon: _searchController.text.isNotEmpty MySpacing.width(10),
? GestureDetector(
onTap: () { // Three dots menu (Manage Reporting)
_searchController.clear(); Container(
_filterEmployees(''); height: 35,
}, width: 35,
child: const Icon(Icons.close, size: 18, color: Colors.grey), decoration: BoxDecoration(
) color: Colors.white,
: null, border: Border.all(color: Colors.grey.shade300),
), borderRadius: BorderRadius.circular(5),
onChanged: (_) => _filterEmployees(_searchController.text), ),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon:
const Icon(Icons.more_vert, size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = [];
// Section: Actions
menuItems.add(
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Actions",
style: TextStyle(
fontWeight: FontWeight.bold, color: Colors.grey),
),
),
);
// Manage Reporting option
menuItems.add(
PopupMenuItem<int>(
value: 1,
child: Row(
children: [
const Icon(Icons.manage_accounts_outlined,
size: 20, color: Colors.black87),
const SizedBox(width: 10),
const Expanded(child: Text("Manage Reporting")),
Icon(Icons.chevron_right,
size: 20, color: contentTheme.primary),
],
),
onTap: () {
Future.delayed(Duration.zero, () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const ManageReportingBottomSheet(),
);
});
},
),
);
return menuItems;
},
),
),
],
), ),
); );
} }
@ -283,7 +373,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 60), padding: const EdgeInsets.only(top: 60),
child: Center( child: Center(
child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]), child: MyText.bodySmall("No Employees Found",
fontWeight: 600, color: Colors.grey[700]),
), ),
); );
} }

View File

@ -0,0 +1,557 @@
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; // new
const ManageReportingBottomSheet({
super.key,
this.initialEmployee,
this.hideMainSelector = false,
this.renderAsCard = false,
this.hideLoggedUserFromSelection = false, // default false
});
@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 List<Map<String, dynamic>> payload = [];
for (final emp in _selectedPrimary) {
payload.add({
"reportToId": emp.id,
"isPrimary": true,
"isActive": true,
});
}
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 employeeId = _selectedEmployee!.id;
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);
// Optionally refresh the saved hierarchy (not necessary here) but we can call:
await ApiService.getOrganizationHierarchyList(employeeId);
// Keep sheet open and reset reporter selections for next assignment
_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),
onDeleted: () => setState(() {
_selectedEmployee = null;
_selectEmployeeController.clear();
_resetReportersOnly();
}),
),
)
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) {
return 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),
onDeleted: () => 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();
}),
),
),
],
);
}
}

View File

@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
), ),
), ),
), ),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, color: Colors.black),
onPressed: () {},
),
], ],
), ),
); );