From 542f27635a0f5293d35411c5de902004b39adf01 Mon Sep 17 00:00:00 2001 From: Manish Date: Fri, 21 Nov 2025 16:04:40 +0530 Subject: [PATCH] upadted with select enployee sheet --- .../expense/add_expense_controller.dart | 15 +- .../add_payment_request_controller.dart | 15 +- .../assign_task_bottom_sheet .dart | 246 +++++++++--------- .../directory/edit_bucket_bottom_sheet.dart | 181 +++++-------- .../multiple_select_bottomsheet.dart | 30 ++- .../multiple_select_role_bottomsheet.dart | 244 +++++++++++++++++ .../expense/add_expense_bottom_sheet.dart | 61 +++-- .../add_payment_request_bottom_sheet.dart | 108 ++++---- .../payment_request_filter_bottom_sheet.dart | 8 +- .../expense/expense_filter_bottom_sheet.dart | 10 +- 10 files changed, 577 insertions(+), 341 deletions(-) create mode 100644 lib/model/employees/multiple_select_role_bottomsheet.dart diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 11ce63d..cfeed4d 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -50,10 +51,22 @@ class AddExpenseController extends GetxController { final isEditMode = false.obs; final isSearchingEmployees = false.obs; +// --- Paid By (Single + Multi Selection Support) --- + +// single selection + final selectedPaidBy = Rxn(); + + + +// helper setters + void setSelectedPaidBy(EmployeeModel? emp) { + selectedPaidBy.value = emp; + } + // --- Dropdown Selections & Data --- final selectedPaymentMode = Rxn(); final selectedExpenseType = Rxn(); - final selectedPaidBy = Rxn(); + // final selectedPaidBy = Rxn(); final selectedProject = ''.obs; final selectedTransactionDate = Rxn(); diff --git a/lib/controller/finance/add_payment_request_controller.dart b/lib/controller/finance/add_payment_request_controller.dart index 1ff0a4e..f05ff45 100644 --- a/lib/controller/finance/add_payment_request_controller.dart +++ b/lib/controller/finance/add_payment_request_controller.dart @@ -14,6 +14,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; import 'package:marco/model/finance/expense_category_model.dart'; import 'package:marco/model/finance/currency_list_model.dart'; +import 'package:marco/model/employees/employee_model.dart'; class AddPaymentRequestController extends GetxController { // Loading States @@ -32,7 +33,7 @@ class AddPaymentRequestController extends GetxController { // Selected Values final selectedProject = Rx?>(null); final selectedCategory = Rx(null); - final selectedPayee = ''.obs; + final selectedPayee = Rx(null); final selectedCurrency = Rx(null); final isAdvancePayment = false.obs; final selectedDueDate = Rx(null); @@ -161,7 +162,7 @@ class AddPaymentRequestController extends GetxController { try { final pickedFile = await _picker.pickImage(source: ImageSource.camera); if (pickedFile != null) { - isProcessingAttachment.value = true; + isProcessingAttachment.value = true; File imageFile = File(pickedFile.path); // Add timestamp to the captured image @@ -184,7 +185,7 @@ class AddPaymentRequestController extends GetxController { selectedProject.value = project; void selectCategory(ExpenseCategory category) => selectedCategory.value = category; - void selectPayee(String payee) => selectedPayee.value = payee; + void selectPayee(EmployeeModel payee) => selectedPayee.value = payee; void selectCurrency(Currency currency) => selectedCurrency.value = currency; void addAttachment(File file) => attachments.add(file); @@ -268,7 +269,7 @@ class AddPaymentRequestController extends GetxController { "amount": double.tryParse(amountController.text.trim()) ?? 0, "currencyId": selectedCurrency.value?.id ?? '', "description": descriptionController.text.trim(), - "payee": selectedPayee.value, + "payee": selectedPayee.value?.id ?? "", "dueDate": selectedDueDate.value?.toIso8601String(), "isAdvancePayment": isAdvancePayment.value, "billAttachments": billAttachments.map((a) { @@ -337,7 +338,7 @@ class AddPaymentRequestController extends GetxController { "amount": double.tryParse(amountController.text.trim()) ?? 0, "currencyId": selectedCurrency.value?.id ?? '', "description": descriptionController.text.trim(), - "payee": selectedPayee.value, + "payee": selectedPayee.value?.id ?? "", "dueDate": selectedDueDate.value?.toIso8601String(), "isAdvancePayment": isAdvancePayment.value, "billAttachments": billAttachments.map((a) { @@ -388,7 +389,7 @@ class AddPaymentRequestController extends GetxController { return _errorSnackbar("Please select a project"); if (selectedCategory.value == null) return _errorSnackbar("Please select a category"); - if (selectedPayee.value.isEmpty) + if (selectedPayee.value == null) return _errorSnackbar("Please select a payee"); if (selectedCurrency.value == null) return _errorSnackbar("Please select currency"); @@ -408,7 +409,7 @@ class AddPaymentRequestController extends GetxController { descriptionController.clear(); selectedProject.value = null; selectedCategory.value = null; - selectedPayee.value = ''; + selectedPayee.value = null; selectedCurrency.value = null; isAdvancePayment.value = false; attachments.clear(); diff --git a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart index fdfcced..5191079 100644 --- a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart @@ -1,3 +1,6 @@ +// Updated AssignTaskBottomSheet with bottom sheet height fix +// Only modified layout for employee selection area to prevent overflow. + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; @@ -12,10 +15,8 @@ import 'package:marco/helpers/widgets/tenant/organization_selector.dart'; import 'package:marco/helpers/widgets/tenant/service_selector.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/tenant/tenant_services_model.dart'; - -// Added imports for employee selection import 'package:marco/model/employees/employee_model.dart'; -import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; +import 'package:marco/model/employees/multiple_select_role_bottomsheet.dart'; class AssignTaskBottomSheet extends StatefulWidget { final String workLocation; @@ -53,9 +54,9 @@ class _AssignTaskBottomSheetState extends State { final TextEditingController targetController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); - final ScrollController _employeeListScrollController = ScrollController(); String? selectedProjectId; + String? selectedRoleId; Organization? selectedOrganization; Service? selectedService; @@ -84,13 +85,14 @@ class _AssignTaskBottomSheetState extends State { serviceId: selectedService?.id, organizationId: selectedOrganization?.id, ); - await controller.fetchTaskData(selectedProjectId, - serviceId: selectedService?.id); + await controller.fetchTaskData( + selectedProjectId, + serviceId: selectedService?.id, + ); } @override void dispose() { - _employeeListScrollController.dispose(); targetController.dispose(); descriptionController.dispose(); super.dispose(); @@ -98,20 +100,21 @@ class _AssignTaskBottomSheetState extends State { @override Widget build(BuildContext context) { - return Obx(() => BaseBottomSheet( - title: "Assign Task", - child: _buildAssignTaskForm(), - onCancel: () => Get.back(), - onSubmit: _onAssignTaskPressed, - isSubmitting: controller.isAssigningTask.value, - )); + return Obx( + () => BaseBottomSheet( + title: "Assign Task", + child: _buildAssignTaskForm(), + onCancel: () => Get.back(), + onSubmit: _onAssignTaskPressed, + isSubmitting: controller.isAssigningTask.value, + ), + ); } Widget _buildAssignTaskForm() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Organization Selector SizedBox( height: 50, child: OrganizationSelector( @@ -123,9 +126,9 @@ class _AssignTaskBottomSheetState extends State { }, ), ), + MySpacing.height(12), - // Service Selector SizedBox( height: 50, child: ServiceSelector( @@ -137,40 +140,27 @@ class _AssignTaskBottomSheetState extends State { }, ), ), + MySpacing.height(16), - - // Work Location Info - _infoRow( - Icons.location_on, - "Work Location", - "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}", - ), + _infoRow(Icons.location_on, "Work Location", + "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), const Divider(), - - // Pending Task Info _infoRow( Icons.pending_actions, "Pending Task", "${widget.pendingTask}"), const Divider(), - // Role Selector (kept as-is) GestureDetector( onTap: _onRoleMenuPressed, - child: Row( - children: [ - MyText.titleMedium("Select Team :", fontWeight: 600), - const SizedBox(width: 4), - const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), - ], - ), + child: Row(children: [ + MyText.titleMedium("Select Team :", fontWeight: 600), + const SizedBox(width: 4), + const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), + ]), ), + MySpacing.height(8), - // ------------------------------- - // Employee selector (REPLACED) - // ------------------------------- - // We show a button-like container (with border) that opens the reusable - // EmployeeSelectionBottomSheet. Selected employees are reflected using - // existing controller.uploadingStates & controller.selectedEmployees. + /// TEAM SELECT BOX GestureDetector( onTap: _openEmployeeSelectionSheet, child: Container( @@ -180,41 +170,47 @@ class _AssignTaskBottomSheetState extends State { borderRadius: BorderRadius.circular(6), ), child: Row( - children: [ - const Icon(Icons.group, color: Colors.black54), - const SizedBox(width: 10), - Expanded( - child: Obx(() { - final count = controller.selectedEmployees.length; - if (count == 0) { - return MyText("Select team members", - color: Colors.grey.shade700); - } - // show summary text when there are selected employees - final names = controller.selectedEmployees - .map((e) => e.name) - .join(", "); - return Text( - names, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ); - }), - ), - const SizedBox(width: 8), - const Icon(Icons.arrow_drop_down, color: Colors.grey), - ], - ), + children: [ + const Icon(Icons.group, color: Colors.black54), + const SizedBox(width: 10), + + // Expanded name area + Expanded( + child: Obx(() { + final count = controller.selectedEmployees.length; + if (count == 0) { + return MyText( + "Select team members", + color: Colors.grey.shade700, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + final names = controller.selectedEmployees + .map((e) => e.name) + .join(", "); + + return Text( + names, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ); + }), + ), + + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], +) + ), ), - MySpacing.height(8), - // Selected Employees Chips (keeps existing behavior) + MySpacing.height(8), _buildSelectedEmployees(), MySpacing.height(8), - // Target Input _buildTextField( icon: Icons.track_changes, label: "Target for Today :", @@ -223,9 +219,9 @@ class _AssignTaskBottomSheetState extends State { keyboardType: const TextInputType.numberWithOptions(decimal: true), validatorType: "target", ), + MySpacing.height(16), - // Description Input _buildTextField( icon: Icons.description, label: "Description :", @@ -253,21 +249,21 @@ class _AssignTaskBottomSheetState extends State { ), items: [ const PopupMenuItem(value: 'all', child: Text("All Roles")), - ...controller.roles.map((role) { - return PopupMenuItem( + ...controller.roles.map( + (role) => PopupMenuItem( value: role['id'].toString(), child: Text(role['name'] ?? 'Unknown Role'), - ); - }), + ), + ), ], ).then((value) { - if (value != null) - controller.onRoleSelected(value == 'all' ? null : value); + if (value != null) { + selectedRoleId = value == 'all' ? null : value; + controller.onRoleSelected(selectedRoleId); + } }); } - // Removed old inline employee list; selection handled by bottom sheet. - Widget _buildSelectedEmployees() { return Obx(() { if (controller.selectedEmployees.isEmpty) return Container(); @@ -319,7 +315,9 @@ class _AssignTaskBottomSheetState extends State { maxLines: maxLines, decoration: InputDecoration( hintText: hintText, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + ), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), @@ -344,61 +342,62 @@ class _AssignTaskBottomSheetState extends State { text: TextSpan( children: [ WidgetSpan( - child: MyText.titleMedium("$title: ", - fontWeight: 600, color: Colors.black), + child: MyText.titleMedium( + "$title: ", + fontWeight: 600, + color: Colors.black, + ), ), TextSpan( - text: value, style: const TextStyle(color: Colors.black)), + text: value, + style: const TextStyle(color: Colors.black), + ), ], ), ), - ), + ) ], ), ); } Future _openEmployeeSelectionSheet() async { - // Open the existing EmployeeSelectionBottomSheet final result = await showModalBottomSheet>( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => EmployeeSelectionBottomSheet( - initiallySelected: controller.selectedEmployees.toList(), - multipleSelection: true, - title: 'Select Team Members', - ), + builder: (context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + minChildSize: 0.6, + maxChildSize: 1.0, + builder: (_, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + + child: MultipleSelectRoleBottomSheet( + projectId: selectedProjectId!, + organizationId: selectedOrganization?.id, + serviceId: selectedService?.id, + roleId: selectedRoleId, + initiallySelected: controller.selectedEmployees.toList(), + scrollController: scrollController, + ), + ); + }, + ); + }, ); - if (result == null) return; - - // Merge returned employees into controller.uploadingStates & controller.selectedEmployees - // 1) Reset all uploadingStates to false, then set true for selected - controller.uploadingStates.forEach((key, rx) { - rx.value = false; - }); - - for (final emp in result) { - final idStr = emp.id.toString(); - if (controller.uploadingStates.containsKey(idStr)) { - controller.uploadingStates[idStr]?.value = true; - } else { - // if uploadingStates doesn't have the id yet, add it (safe fallback) - controller.uploadingStates[idStr] = RxBool(true); - } - } - - // 2) Update selectedEmployees list in controller - controller.selectedEmployees.assignAll(result); - - // 3) Call controller helper (keeps existing behavior) - try { + if (result != null) { + controller.selectedEmployees.assignAll(result); controller.updateSelectedEmployees(); - } catch (_) { - // If controller does not implement updateSelectedEmployees, ignore. } } @@ -410,18 +409,20 @@ class _AssignTaskBottomSheetState extends State { if (selectedTeam.isEmpty) { showAppSnackbar( - title: "Team Required", - message: "Please select at least one team member", - type: SnackbarType.error); + title: "Team Required", + message: "Please select at least one team member", + type: SnackbarType.error, + ); return; } final target = double.tryParse(targetController.text.trim()); if (target == null || target <= 0) { showAppSnackbar( - title: "Invalid Input", - message: "Please enter a valid target number", - type: SnackbarType.error); + title: "Invalid Input", + message: "Please enter a valid target number", + type: SnackbarType.error, + ); return; } @@ -437,9 +438,10 @@ class _AssignTaskBottomSheetState extends State { final description = descriptionController.text.trim(); if (description.isEmpty) { showAppSnackbar( - title: "Description Required", - message: "Please enter a description", - type: SnackbarType.error); + title: "Description Required", + message: "Please enter a description", + type: SnackbarType.error, + ); return; } diff --git a/lib/model/directory/edit_bucket_bottom_sheet.dart b/lib/model/directory/edit_bucket_bottom_sheet.dart index 7347b63..d115377 100644 --- a/lib/model/directory/edit_bucket_bottom_sheet.dart +++ b/lib/model/directory/edit_bucket_bottom_sheet.dart @@ -9,6 +9,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; class EditBucketBottomSheet { static void show( @@ -21,10 +22,8 @@ class EditBucketBottomSheet { final nameController = TextEditingController(text: bucket.name); final descController = TextEditingController(text: bucket.description); - final searchController = TextEditingController(); final selectedIds = RxSet({...bucket.employeeIds}); - final searchText = ''.obs; InputDecoration _inputDecoration(String label) { return InputDecoration( @@ -84,6 +83,15 @@ class EditBucketBottomSheet { } } + Future _handleSubmitBottomSheet(BuildContext sheetContext) async { + await _handleSubmit(); + + // close bottom sheet safely + if (Navigator.of(sheetContext).canPop()) { + Navigator.of(sheetContext).pop(); + } + } + Widget _formContent() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -101,117 +109,72 @@ class EditBucketBottomSheet { MySpacing.height(20), MyText.labelLarge('Shared With', fontWeight: 600), MySpacing.height(8), - Obx(() => TextField( - controller: searchController, - onChanged: (value) => searchText.value = value.toLowerCase(), - decoration: InputDecoration( - hintText: 'Search employee...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: searchText.value.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - searchController.clear(); - searchText.value = ''; - }, - ) - : null, - isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - )), - MySpacing.height(8), Obx(() { - final filtered = allEmployees.where((emp) { - final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase(); - return fullName.contains(searchText.value); - }).toList(); + if (selectedIds.isEmpty) return const SizedBox.shrink(); - return SizedBox( - height: 180, - child: ListView.separated( - itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 2), - itemBuilder: (context, index) { - final emp = filtered[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + final selectedEmployees = + allEmployees.where((e) => selectedIds.contains(e.id)).toList(); - return Obx(() => Theme( - data: Theme.of(context).copyWith( - unselectedWidgetColor: Colors.grey.shade500, - checkboxTheme: CheckboxThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4)), - side: const BorderSide(color: Colors.grey), - fillColor: - MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Colors.blueAccent; - } - return Colors.white; - }), - checkColor: MaterialStateProperty.all(Colors.white), - ), - ), - child: CheckboxListTile( - dense: true, - contentPadding: EdgeInsets.zero, - visualDensity: const VisualDensity(vertical: -4), - controlAffinity: ListTileControlAffinity.leading, - value: selectedIds.contains(emp.id), - onChanged: emp.id == ownerId - ? null - : (val) { - if (val == true) { - selectedIds.add(emp.id); - } else { - selectedIds.remove(emp.id); - } - }, - title: Row( - children: [ - Expanded( - child: MyText.bodyMedium( - fullName.isNotEmpty ? fullName : 'Unnamed', - fontWeight: 600, - ), - ), - if (emp.id == ownerId) - Container( - margin: const EdgeInsets.only(left: 6), - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(4), - ), - child: MyText.labelSmall( - "Owner", - fontWeight: 600, - color: Colors.red, - ), - ), - ], - ), - subtitle: emp.jobRole.isNotEmpty - ? MyText.bodySmall( - emp.jobRole, - color: Colors.grey.shade600, - ) - : null, - ), - )); - }, - ), + return Wrap( + spacing: 8, + children: selectedEmployees.map((emp) { + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return Chip( + label: Text(fullName), + onDeleted: emp.id == ownerId + ? null + : () => selectedIds.remove(emp.id), + ); + }).toList(), ); }), + MySpacing.height(8), + +// --- Open new EmployeeSelectionBottomSheet --- + GestureDetector( + onTap: () async { + final initiallySelected = allEmployees + .where((e) => selectedIds.contains(e.id)) + .toList(); + + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (_) => EmployeeSelectionBottomSheet( + initiallySelected: initiallySelected, + multipleSelection: true, + title: "Shared With", + ), + ); + + if (result != null) { + selectedIds + ..clear() + ..addAll(result.map((e) => e.id)); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: const [ + Icon(Icons.search, color: Colors.grey), + SizedBox(width: 8), + Expanded(child: Text("Search & Select Employees")), + ], + ), + ), + ), + MySpacing.height(8), + const SizedBox.shrink(), ], ); } @@ -224,7 +187,7 @@ class EditBucketBottomSheet { return BaseBottomSheet( title: "Edit Bucket", onCancel: () => Navigator.pop(context), - onSubmit: _handleSubmit, + onSubmit: () => _handleSubmitBottomSheet(context), child: _formContent(), ); }, diff --git a/lib/model/employees/multiple_select_bottomsheet.dart b/lib/model/employees/multiple_select_bottomsheet.dart index 0e11530..f96e361 100644 --- a/lib/model/employees/multiple_select_bottomsheet.dart +++ b/lib/model/employees/multiple_select_bottomsheet.dart @@ -159,6 +159,26 @@ class _EmployeeSelectionBottomSheetState final emp = results[index]; final isSelected = _selectedEmployees.contains(emp); + Widget trailingWidget; + + if (widget.multipleSelection) { + // Multiple selection → normal checkbox + trailingWidget = Checkbox( + value: isSelected, + onChanged: (_) => _toggleEmployee(emp), + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.blueAccent + : Colors.white, + ), + ); + } else { + // Single selection → check circle + trailingWidget = isSelected + ? const Icon(Icons.check_circle, color: Colors.blueAccent) + : const Icon(Icons.circle_outlined, color: Colors.grey); + } + return ListTile( leading: CircleAvatar( backgroundColor: Colors.blueAccent, @@ -170,15 +190,7 @@ class _EmployeeSelectionBottomSheetState ), title: Text('${emp.firstName} ${emp.lastName}'), subtitle: Text(emp.email), - trailing: Checkbox( - value: isSelected, - onChanged: (_) => _toggleEmployee(emp), - fillColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.blueAccent - : Colors.white, - ), - ), + trailing: trailingWidget, onTap: () => _toggleEmployee(emp), contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), diff --git a/lib/model/employees/multiple_select_role_bottomsheet.dart b/lib/model/employees/multiple_select_role_bottomsheet.dart new file mode 100644 index 0000000..1ffd1e0 --- /dev/null +++ b/lib/model/employees/multiple_select_role_bottomsheet.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; + +class MultipleSelectRoleBottomSheet extends StatefulWidget { + final String title; + final bool multipleSelection; + + final String projectId; + final String? serviceId; + final String? organizationId; + final String? roleId; + final ScrollController? scrollController; + + final List initiallySelected; + + const MultipleSelectRoleBottomSheet({ + super.key, + this.title = "Select Employees", + this.multipleSelection = true, + required this.projectId, + this.serviceId, + this.organizationId, + this.roleId, + this.initiallySelected = const [], + this.scrollController, + }); + + @override + State createState() => + _MultipleSelectRoleBottomSheetState(); +} + +class _MultipleSelectRoleBottomSheetState + extends State { + final RxList _employees = [].obs; + final RxList _filtered = [].obs; + final RxBool _isLoading = true.obs; + + late RxList _selected; + final TextEditingController _searchController = TextEditingController(); + + late DailyTaskPlanningController controller; + + @override + void initState() { + super.initState(); + _selected = widget.initiallySelected.obs; + controller = Get.find(); + _fetchEmployeesFiltered(); + } + + Future _fetchEmployeesFiltered() async { + _isLoading.value = true; + try { + List employees = controller.employees.toList(); + + if (widget.roleId != null && widget.roleId!.isNotEmpty) { + employees = employees + .where((emp) => emp.jobRoleID == widget.roleId) + .toList(); + } + + // Selected first + employees.sort((a, b) { + final aSel = _selected.any((e) => e.id == a.id) ? 0 : 1; + final bSel = _selected.any((e) => e.id == b.id) ? 0 : 1; + return aSel != bSel + ? aSel.compareTo(bSel) + : a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + _employees.assignAll(employees); + _filtered.assignAll(employees); + } catch (e) { + print("Error fetching employees: $e"); + } finally { + _isLoading.value = false; + } + } + + void _onSearch(String text) { + if (text.isEmpty) { + _filtered.assignAll(_employees); + } else { + _filtered.assignAll( + _employees.where((e) => + e.name.toLowerCase().contains(text.toLowerCase()) || + e.designation.toLowerCase().contains(text.toLowerCase())), + ); + } + + // Selected on top + _filtered.sort((a, b) { + final aSel = _selected.any((e) => e.id == a.id) ? 0 : 1; + final bSel = _selected.any((e) => e.id == b.id) ? 0 : 1; + return aSel != bSel + ? aSel.compareTo(bSel) + : a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + } + + void _onTap(EmployeeModel emp) { + if (widget.multipleSelection) { + if (_selected.any((e) => e.id == emp.id)) { + _selected.removeWhere((e) => e.id == emp.id); + } else { + _selected.add(emp); + } + } else { + _selected.assignAll([emp]); + Get.back(result: _selected); + } + + _onSearch(_searchController.text.trim()); + } + + bool _isSelected(EmployeeModel emp) { + return _selected.any((e) => e.id == emp.id); + } + + Widget _searchBar() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextField( + controller: _searchController, + onChanged: _onSearch, + decoration: InputDecoration( + hintText: 'Search employees...', + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(Icons.search, color: Colors.grey), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close, color: Colors.grey), + onPressed: () { + _searchController.clear(); + _onSearch(''); + }, + ) + : null, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + ), + ), + ); + + /// ⭐ NEW — Chips showing selected employees + Widget _selectedChips() { + return Obx(() { + if (_selected.isEmpty) return const SizedBox(); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: _selected.map((emp) { + return Chip( + label: Text(emp.name), + deleteIcon: const Icon(Icons.close), + onDeleted: () { + _selected.remove(emp); + _onSearch(_searchController.text.trim()); + }, + backgroundColor: Colors.blue.shade50, + ); + }).toList(), + ); + }); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: widget.title, + onCancel: () => Get.back(), + onSubmit: () => Get.back(result: _selected), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.55, + child: Column( + children: [ + _searchBar(), + + /// ⭐ Chips shown right below search bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: _selectedChips(), + ), + + const SizedBox(height: 6), + + Expanded( + child: Obx(() { + if (_isLoading.value) { + return SkeletonLoaders.employeeSkeletonCard(); + } + + if (_filtered.isEmpty) { + return const Center(child: Text("No employees found")); + } + + return ListView.builder( + controller: widget.scrollController, + padding: const EdgeInsets.only(bottom: 20), + itemCount: _filtered.length, + itemBuilder: (_, index) { + final emp = _filtered[index]; + final isSelected = _isSelected(emp); + + return ListTile( + onTap: () => _onTap(emp), + leading: CircleAvatar( + backgroundColor: Colors.blueAccent, + child: Text( + emp.name.isNotEmpty ? emp.name[0].toUpperCase() : "?", + style: const TextStyle(color: Colors.white), + ), + ), + title: Text(emp.name), + subtitle: Text(emp.designation), + trailing: Checkbox( + value: isSelected, + onChanged: (_) => _onTap(emp), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + ); + }, + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 317bf9b..ca714ba 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -5,13 +5,15 @@ import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; -import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; +// import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/validators.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; /// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ @@ -52,24 +54,36 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> /// Show employee list Future _showEmployeeList() async { - await showModalBottomSheet( + final result = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (_) => ReusableEmployeeSelectorBottomSheet( - searchController: controller.employeeSearchController, - searchResults: controller.employeeSearchResults, - isSearching: controller.isSearchingEmployees, - onSearch: controller.searchEmployees, - onSelect: (emp) => controller.selectedPaidBy.value = emp, + builder: (_) => EmployeeSelectionBottomSheet( + initiallySelected: controller.selectedPaidBy.value != null + ? [controller.selectedPaidBy.value!] + : [], + multipleSelection: false, + title: "Select Paid By", ), ); - controller.employeeSearchController.clear(); - controller.employeeSearchResults.clear(); + if (result == null) return; + + // result will be EmployeeModel or [EmployeeModel] + if (result is EmployeeModel) { + controller.setSelectedPaidBy(result); + } else if (result is List && result.isNotEmpty) { + controller.setSelectedPaidBy(result.first as EmployeeModel); + } + + // cleanup + try { + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } catch (_) {} } /// Generic option list @@ -343,23 +357,26 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> const SectionTitle( icon: Icons.person_outline, title: "Paid By", requiredField: true), MySpacing.height(6), + // Main tile: tap to choose mode + selection sheet GestureDetector( onTap: _showEmployeeList, child: TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - style: const TextStyle(fontSize: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + controller.selectedPaidBy.value?.name ?? "Select Paid By", + style: TextStyle(fontSize: 15), + overflow: TextOverflow.ellipsis, ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), + ), + Icon(Icons.arrow_drop_down, size: 22), + ], + )), ), + // small helper: long-press to quickly open multi-select directly (optional) + const SizedBox(height: 6), ], ); } diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart index abe1527..a23bfcf 100644 --- a/lib/model/finance/add_payment_request_bottom_sheet.dart +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -9,6 +9,8 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; +import 'package:marco/model/employees/employee_model.dart'; Future showPaymentRequestBottomSheet({ bool isEdit = false, @@ -206,7 +208,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ? null : "Enter valid amount"), _gap(), - _buildPayeeAutocompleteField(), + _buildPayeeField(), _gap(), _buildDropdown( "Currency", @@ -347,74 +349,35 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ); } - Widget _buildPayeeAutocompleteField() { + Widget _buildPayeeField() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionTitle( - icon: Icons.person_outline, title: "Payee", requiredField: true), - const SizedBox(height: 6), - Autocomplete( - optionsBuilder: (textEditingValue) { - final query = textEditingValue.text.toLowerCase(); - return query.isEmpty - ? const Iterable.empty() - : controller.payees - .where((p) => p.toLowerCase().contains(query)); - }, - displayStringForOption: (option) => option, - fieldViewBuilder: - (context, fieldController, focusNode, onFieldSubmitted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (fieldController.text != controller.selectedPayee.value) { - fieldController.text = controller.selectedPayee.value; - fieldController.selection = TextSelection.fromPosition( - TextPosition(offset: fieldController.text.length)); - } - }); - - return TextFormField( - controller: fieldController, - focusNode: focusNode, - decoration: InputDecoration( - hintText: "Type or select payee", - filled: true, - fillColor: Colors.grey.shade100, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), + const SectionTitle( + icon: Icons.person_outline, + title: "Payee", + requiredField: true, + ), + MySpacing.height(6), + GestureDetector( + onTap: _showPayeeSelector, + child: TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Obx(() => Text( + controller.selectedPayee.value?.name ?? "Select Payee", + style: const TextStyle(fontSize: 15), + overflow: TextOverflow.ellipsis, + )), ), - ), - validator: (v) => - v == null || v.trim().isEmpty ? "Please enter payee" : null, - onChanged: (val) => controller.selectedPayee.value = val, - ); - }, - onSelected: (selection) => controller.selectedPayee.value = selection, - optionsViewBuilder: (context, onSelected, options) => Material( - color: Colors.white, - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200, minWidth: 300), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (_, index) => InkWell( - onTap: () => onSelected(options.elementAt(index)), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 12), - child: Text(options.elementAt(index), - style: const TextStyle(fontSize: 14)), - ), - ), - ), + const Icon(Icons.arrow_drop_down, size: 22), + ], ), ), ), + const SizedBox(height: 6), ], ); } @@ -533,7 +496,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> if (controller.selectedCategory.value == null) { return _showError("Please select a category"); } - if (controller.selectedPayee.value.isEmpty) { + if (controller.selectedPayee.value == null) { return _showError("Please select a payee"); } if (controller.selectedCurrency.value == null) { @@ -542,6 +505,25 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> return true; } + Future _showPayeeSelector() async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => EmployeeSelectionBottomSheet( + title: "Select Payee", + multipleSelection: false, + initiallySelected: controller.selectedPayee.value != null + ? [controller.selectedPayee.value!] + : [], + ), + ); + + if (result is EmployeeModel) { + controller.selectedPayee.value = result; + } + } + bool _showError(String msg) { showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); return false; diff --git a/lib/model/finance/payment_request_filter_bottom_sheet.dart b/lib/model/finance/payment_request_filter_bottom_sheet.dart index 8d680b2..737495d 100644 --- a/lib/model/finance/payment_request_filter_bottom_sheet.dart +++ b/lib/model/finance/payment_request_filter_bottom_sheet.dart @@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/date_range_picker.dart'; import 'package:marco/model/employees/employee_model.dart'; -import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; class PaymentRequestFilterBottomSheet extends StatefulWidget { final PaymentRequestController controller; @@ -441,9 +441,9 @@ class _PaymentRequestFilterBottomSheetState shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => EmployeeSelectorBottomSheet( - selectedEmployees: selectedEmployees, - searchEmployees: (query) => searchEmployees(query, items), + builder: (context) => EmployeeSelectionBottomSheet( + initiallySelected: selectedEmployees.toList(), + multipleSelection: true, title: title, ), ); diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 0c6d565..380aed2 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -8,9 +8,10 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/employees/employee_model.dart'; -import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/date_range_picker.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; + class ExpenseFilterBottomSheet extends StatefulWidget { final ExpenseController expenseController; @@ -303,12 +304,13 @@ class _ExpenseFilterBottomSheetState extends State shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => EmployeeSelectorBottomSheet( - selectedEmployees: selectedEmployees, - searchEmployees: searchEmployees, + builder: (context) => EmployeeSelectionBottomSheet( + initiallySelected: selectedEmployees.toList(), + multipleSelection: true, title: title, ), ); + if (result != null) selectedEmployees.assignAll(result); }, child: Container(