From 8619a9e43e0006421fb47d486f1c12fe587f06cb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 14 Nov 2025 15:35:41 +0530 Subject: [PATCH] created new emploee selector bottomsheet --- .../add_service_project_job_controller.dart | 118 ++++++++ ...ice_project_details_screen_controller.dart | 88 +++++- lib/helpers/services/api_endpoints.dart | 3 + lib/helpers/services/api_service.dart | 169 +++++++++-- lib/helpers/utils/permission_constants.dart | 3 + lib/helpers/widgets/date_range_picker.dart | 24 +- lib/helpers/widgets/my_snackbar.dart | 2 +- lib/model/employees/employee_model.dart | 10 + .../multiple_select_bottomsheet.dart | 159 ++++++++++ .../add_service_project_job_bottom_sheet.dart | 241 +++++++++++++++ lib/model/service_project/job_list_model.dart | 237 +++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 3 + .../service_project_details_screen.dart | 279 +++++++++++++++--- .../service_project_screen.dart | 40 ++- 14 files changed, 1289 insertions(+), 87 deletions(-) create mode 100644 lib/controller/service_project/add_service_project_job_controller.dart create mode 100644 lib/model/employees/multiple_select_bottomsheet.dart create mode 100644 lib/model/service_project/add_service_project_job_bottom_sheet.dart create mode 100644 lib/model/service_project/job_list_model.dart diff --git a/lib/controller/service_project/add_service_project_job_controller.dart b/lib/controller/service_project/add_service_project_job_controller.dart new file mode 100644 index 0000000..6195dab --- /dev/null +++ b/lib/controller/service_project/add_service_project_job_controller.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class AddServiceProjectJobController extends GetxController { + // Form Controllers + final titleCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final tagCtrl = TextEditingController(); + final FocusNode searchFocusNode = FocusNode(); + final RxBool showEmployeePicker = true.obs; + + // Observables + final startDate = Rx(DateTime.now()); + final dueDate = Rx(DateTime.now().add(const Duration(days: 1))); + final enteredTags = [].obs; + + final employees = [].obs; + final selectedAssignees = [].obs; + final isSearchingEmployees = false.obs; + + // Loading states + final isLoading = false.obs; + final isAllEmployeeLoading = false.obs; + final allEmployees = [].obs; + final employeeSearchResults = [].obs; + + @override + void onInit() { + super.onInit(); + searchEmployees(""); // pass empty string safely + } + + @override + void onClose() { + titleCtrl.dispose(); + descCtrl.dispose(); + tagCtrl.dispose(); + super.onClose(); + } + + Future searchEmployees(String query) async { + if (query.trim().isEmpty) { + employeeSearchResults.clear(); + return; + } + isSearchingEmployees.value = true; + try { + final data = + await ApiService.searchEmployeesBasic(searchString: query.trim()); + if (data is List) { + employeeSearchResults.assignAll( + data + .map((e) => EmployeeModel.fromJson(e as Map)) + .toList(), + ); + } else { + employeeSearchResults.clear(); + } + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + + /// Toggle employee selection + void toggleAssignee(EmployeeModel employee) { + if (selectedAssignees.contains(employee)) { + selectedAssignees.remove(employee); + } else { + selectedAssignees.add(employee); + } + } + + /// Create Service Project Job API call + Future createJob(String projectId) async { + if (titleCtrl.text.trim().isEmpty || descCtrl.text.trim().isEmpty) { + showAppSnackbar( + title: "Validation", + message: "Title and Description are required", + type: SnackbarType.warning, + ); + return; + } + + final assigneeIds = selectedAssignees.map((e) => e.id).toList(); + + final success = await ApiService.createServiceProjectJobApi( + title: titleCtrl.text.trim(), + description: descCtrl.text.trim(), + projectId: projectId, + assignees: assigneeIds.map((id) => {"id": id}).toList(), + startDate: startDate.value!, + dueDate: dueDate.value!, + tags: enteredTags.map((tag) => {"name": tag}).toList(), + ); + + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Job created successfully", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create job", + type: SnackbarType.error, + ); + } + } +} diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart index 65b9f38..2761723 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/service_project/service_projects_details_model.dart'; +import 'package:marco/model/service_project/job_list_model.dart'; class ServiceProjectDetailsController extends GetxController { // Selected project id @@ -9,19 +10,39 @@ class ServiceProjectDetailsController extends GetxController { // Project details var projectDetail = Rxn(); - // Loading state + // Job list + var jobList = [].obs; + + // Loading states var isLoading = false.obs; + var isJobLoading = false.obs; - // Error message + // Error messages var errorMessage = ''.obs; + var jobErrorMessage = ''.obs; - /// Set project id and fetch its details + // Pagination + var pageNumber = 1; + final int pageSize = 20; + var hasMoreJobs = true.obs; + + @override + void onInit() { + super.onInit(); + // Fetch job list initially even if projectId is empty + fetchProjectJobs(initialLoad: true); + } + + /// Set project id and fetch its details + jobs void setProjectId(String id) { projectId.value = id; fetchProjectDetail(); + pageNumber = 1; + hasMoreJobs.value = true; + fetchProjectJobs(initialLoad: true); } - /// Fetch project detail from API + /// Fetch project detail Future fetchProjectDetail() async { if (projectId.value.isEmpty) { errorMessage.value = "Invalid project ID"; @@ -32,12 +53,14 @@ class ServiceProjectDetailsController extends GetxController { errorMessage.value = ''; try { - final result = await ApiService.getServiceProjectDetailApi(projectId.value); + final result = + await ApiService.getServiceProjectDetailApi(projectId.value); if (result != null && result.data != null) { projectDetail.value = result.data!; } else { - errorMessage.value = result?.message ?? "Failed to fetch project details"; + errorMessage.value = + result?.message ?? "Failed to fetch project details"; } } catch (e) { errorMessage.value = "Error: $e"; @@ -46,8 +69,57 @@ class ServiceProjectDetailsController extends GetxController { } } - /// Refresh project details manually + /// Fetch project job list + Future fetchProjectJobs({bool initialLoad = false}) async { + if (projectId.value.isEmpty && !initialLoad) { + jobErrorMessage.value = "Invalid project ID"; + return; + } + + if (!hasMoreJobs.value && !initialLoad) return; + + isJobLoading.value = true; + jobErrorMessage.value = ''; + + try { + final result = await ApiService.getServiceProjectJobListApi( + projectId: "", + pageNumber: pageNumber, + pageSize: pageSize, + isActive: true, + ); + + if (result != null && result.data != null) { + if (initialLoad) { + jobList.value = result.data?.data ?? []; + } else { + jobList.addAll(result.data?.data ?? []); + } + + hasMoreJobs.value = (result.data?.data?.length ?? 0) == pageSize; + if (hasMoreJobs.value) pageNumber++; + } else { + jobErrorMessage.value = result?.message ?? "Failed to fetch job list"; + } + } catch (e) { + jobErrorMessage.value = "Error fetching jobs: $e"; + } finally { + isJobLoading.value = false; + } + } + + /// Fetch more jobs for pagination + Future fetchMoreJobs() async { + await fetchProjectJobs(); + } + + /// Manual refresh Future refresh() async { - await fetchProjectDetail(); + pageNumber = 1; + hasMoreJobs.value = true; + await Future.wait([ + fetchProjectDetail(), + fetchProjectJobs(initialLoad: true), + ]); } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index e73e150..35e7831 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -133,4 +133,7 @@ class ApiEndpoints { // Service Project Module API Endpoints static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectDetail = "/serviceproject/details"; + static const String getServiceProjectJobList = "/serviceproject/job/list"; + static const String getServiceProjectJobDetail = "/serviceproject/job/details"; + static const String createServiceProjectJob = "/serviceproject/job/create"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 48e5179..a5e2219 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -35,6 +35,7 @@ import 'package:marco/model/finance/payment_request_details_model.dart'; import 'package:marco/model/finance/advance_payment_model.dart'; import 'package:marco/model/service_project/service_projects_list_model.dart'; import 'package:marco/model/service_project/service_projects_details_model.dart'; +import 'package:marco/model/service_project/job_list_model.dart'; class ApiService { static const bool enableLogs = true; @@ -306,34 +307,156 @@ class ApiService { // Service Project Module APIs - /// Get details of a single service project -static Future getServiceProjectDetailApi(String projectId) async { - final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId"; - logSafe("Fetching details for Service Project ID: $projectId"); + /// Create a new Service Project Job + static Future createServiceProjectJobApi({ + required String title, + required String description, + required String projectId, + required List> assignees, + required DateTime startDate, + required DateTime dueDate, + required List> tags, + }) async { + const endpoint = ApiEndpoints.createServiceProjectJob; + logSafe("Creating Service Project Job for projectId: $projectId"); - try { - final response = await _getRequest(endpoint); + final body = { + "title": title, + "description": description, + "projectId": projectId, + "assignees": assignees, + "startDate": startDate.toIso8601String(), + "dueDate": dueDate.toIso8601String(), + "tags": tags, + }; - if (response == null) { - logSafe("Service Project Detail request failed: null response", level: LogLevel.error); - return null; + try { + final response = await _postRequest(endpoint, body); + + if (response == null) { + logSafe("Create Service Project Job failed: null response", + level: LogLevel.error); + return false; + } + + logSafe( + "Create Service Project Job response status: ${response.statusCode}"); + logSafe("Create Service Project Job response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Service Project Job created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during createServiceProjectJobApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; } - - final jsonResponse = _parseResponseForAllData( - response, - label: "Service Project Detail", - ); - - if (jsonResponse != null) { - return ServiceProjectDetailModel.fromJson(jsonResponse); - } - } catch (e, stack) { - logSafe("Exception during getServiceProjectDetailApi: $e", level: LogLevel.error); - logSafe("StackTrace: $stack", level: LogLevel.debug); } - return null; -} + /// Get Service Project Job List + static Future getServiceProjectJobListApi({ + required String projectId, + int pageNumber = 1, + int pageSize = 20, + bool isActive = true, + }) async { + const endpoint = ApiEndpoints.getServiceProjectJobList; + logSafe("Fetching Job List for Service Project: $projectId"); + + try { + final queryParams = { + 'projectId': projectId, + 'pageNumber': pageNumber.toString(), + 'pageSize': pageSize.toString(), + 'isActive': isActive.toString(), + }; + + final response = await _getRequest(endpoint, queryParams: queryParams); + + if (response == null) { + logSafe("Service Project Job List request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job List", + ); + + if (jsonResponse != null) { + return JobResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobListApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + +// API to get all employees from basic + static Future?> allEmployeesBasic({ + bool allEmployee = true, + }) async { + final queryParams = {}; + + // Always include allEmployee parameter + queryParams['allEmployee'] = allEmployee.toString(); + + final response = await _getRequest( + ApiEndpoints.getEmployeesWithoutPermission, + queryParams: queryParams, + ); + + if (response != null) { + return _parseResponse(response, label: ' All Employees Basic'); + } + + return null; + } + + /// Get details of a single service project + static Future getServiceProjectDetailApi( + String projectId) async { + final endpoint = "${ApiEndpoints.getServiceProjectDetail}/$projectId"; + logSafe("Fetching details for Service Project ID: $projectId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Service Project Detail request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Detail", + ); + + if (jsonResponse != null) { + return ServiceProjectDetailModel.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectDetailApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } /// Get Service Project List static Future getServiceProjectsListApi({ diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 733f6ab..a5e9036 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -160,4 +160,7 @@ class MenuItems { /// Documents menu static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3"; + + /// Service Projects + static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b"; } diff --git a/lib/helpers/widgets/date_range_picker.dart b/lib/helpers/widgets/date_range_picker.dart index 6afab60..56b38e6 100644 --- a/lib/helpers/widgets/date_range_picker.dart +++ b/lib/helpers/widgets/date_range_picker.dart @@ -33,11 +33,15 @@ class _DateRangePickerWidgetState extends State ? widget.startDate.value ?? DateTime.now() : widget.endDate.value ?? DateTime.now(); + // Ensure initialDate is within firstDate..lastDate + final first = DateTime(2000); + final last = current.isAfter(DateTime.now()) ? current : DateTime.now(); + final DateTime? picked = await showDatePicker( context: context, initialDate: current, - firstDate: DateTime(2000), - lastDate: DateTime.now(), + firstDate: first, + lastDate: last, builder: (context, child) => Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( @@ -53,14 +57,22 @@ class _DateRangePickerWidgetState extends State if (picked != null) { if (isStartDate) { widget.startDate.value = picked; + // Auto-adjust endDate if needed + if (widget.endDate.value != null && + widget.endDate.value!.isBefore(picked)) { + widget.endDate.value = picked; + } } else { widget.endDate.value = picked; + // Auto-adjust startDate if needed + if (widget.startDate.value != null && + widget.startDate.value!.isAfter(picked)) { + widget.startDate.value = picked; + } } - if (widget.onDateRangeSelected != null) { - widget.onDateRangeSelected!( - widget.startDate.value, widget.endDate.value); - } + widget.onDateRangeSelected + ?.call(widget.startDate.value, widget.endDate.value); } } diff --git a/lib/helpers/widgets/my_snackbar.dart b/lib/helpers/widgets/my_snackbar.dart index 0b51911..54a6bc8 100644 --- a/lib/helpers/widgets/my_snackbar.dart +++ b/lib/helpers/widgets/my_snackbar.dart @@ -35,7 +35,7 @@ void showAppSnackbar({ message, backgroundColor: backgroundColor, colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, + snackPosition: SnackPosition.TOP, margin: const EdgeInsets.all(16), borderRadius: 8, duration: const Duration(seconds: 5), diff --git a/lib/model/employees/employee_model.dart b/lib/model/employees/employee_model.dart index 63669cc..d774f23 100644 --- a/lib/model/employees/employee_model.dart +++ b/lib/model/employees/employee_model.dart @@ -71,4 +71,14 @@ class EmployeeModel { 'phoneNumber': phoneNumber.isEmpty ? '-' : phoneNumber, }; } + + /// ✅ Add equality based on unique `id` + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is EmployeeModel && other.id == id; + } + + @override + int get hashCode => id.hashCode; } diff --git a/lib/model/employees/multiple_select_bottomsheet.dart b/lib/model/employees/multiple_select_bottomsheet.dart new file mode 100644 index 0000000..34b62bc --- /dev/null +++ b/lib/model/employees/multiple_select_bottomsheet.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/helpers/services/api_service.dart'; + +class EmployeeSelectionBottomSheet extends StatefulWidget { + final List initiallySelected; + final bool multipleSelection; + final String title; + + const EmployeeSelectionBottomSheet({ + Key? key, + this.initiallySelected = const [], + this.multipleSelection = true, + this.title = 'Select Employees', + }) : super(key: key); + + @override + State createState() => + _EmployeeSelectionBottomSheetState(); +} + +class _EmployeeSelectionBottomSheetState + extends State { + final TextEditingController _searchController = TextEditingController(); + final RxBool _isSearching = false.obs; + final RxList _searchResults = [].obs; + late RxList _selectedEmployees; + + @override + void initState() { + super.initState(); + _selectedEmployees = RxList.from(widget.initiallySelected); + _searchEmployees(''); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _searchEmployees(String query) async { + _isSearching.value = true; + final data = await ApiService.searchEmployeesBasic(searchString: query); + final results = (data as List) + .map((e) => EmployeeModel.fromJson(e as Map)) + .toList(); + _searchResults.assignAll(results); + _isSearching.value = false; + } + + void _toggleEmployee(EmployeeModel emp) { + if (widget.multipleSelection) { + if (_selectedEmployees.contains(emp)) { + _selectedEmployees.remove(emp); + } else { + _selectedEmployees.add(emp); + } + _selectedEmployees.refresh(); + } else { + _selectedEmployees.assignAll([emp]); + _selectedEmployees.refresh(); + } + } + + void _handleSubmit() { + if (widget.multipleSelection) { + Navigator.of(context).pop(_selectedEmployees.toList()); + } else { + Navigator.of(context) + .pop(_selectedEmployees.isNotEmpty ? _selectedEmployees.first : null); + } + } + + Widget _searchBar() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextField( + controller: _searchController, + onChanged: _searchEmployees, + 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(); + _searchEmployees(''); + }, + ) + : null, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide.none, + ), + ), + ), + ); + + Widget _employeeList() => Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final emp = _searchResults[index]; + return Obx(() { + // wrap each tile + final isSelected = _selectedEmployees.contains(emp); + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blueAccent, + child: Text( + (emp.firstName.isNotEmpty ? emp.firstName[0] : 'U') + .toUpperCase(), + style: const TextStyle(color: Colors.white), + ), + ), + 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, + ), + ), + onTap: () => _toggleEmployee(emp), + contentPadding: + const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + ); + }); + }, + ), + ); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: widget.title, + onCancel: () => Navigator.of(context).pop(), + onSubmit: _handleSubmit, + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Column(children: [ + _searchBar(), + _employeeList(), + ]), + ), + ); + } +} diff --git a/lib/model/service_project/add_service_project_job_bottom_sheet.dart b/lib/model/service_project/add_service_project_job_bottom_sheet.dart new file mode 100644 index 0000000..64fbbfd --- /dev/null +++ b/lib/model/service_project/add_service_project_job_bottom_sheet.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +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/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; +import 'package:marco/controller/service_project/add_service_project_job_controller.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; + +class AddServiceProjectJobBottomSheet extends StatefulWidget { + final String projectId; + const AddServiceProjectJobBottomSheet({super.key, required this.projectId}); + + @override + State createState() => + _AddServiceProjectJobBottomSheetState(); +} + +class _AddServiceProjectJobBottomSheetState + extends State with UIMixin { + final formKey = GlobalKey(); + final controller = Get.put(AddServiceProjectJobController()); + + final TextEditingController _searchController = TextEditingController(); + late RxList _selectedEmployees; + + @override + void initState() { + super.initState(); + _selectedEmployees = + RxList.from(controller.selectedAssignees); + } + + @override + void dispose() { + _searchController.dispose(); + Get.delete(); + super.dispose(); + } + + InputDecoration _inputDecoration(String hint) => InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + isDense: true, + ); + + Widget _labelWithStar(String label, {bool required = false}) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + MyText.labelMedium(label), + if (required) + const Text(" *", style: TextStyle(color: Colors.red, fontSize: 14)), + ], + ); + + Widget _textField(String label, TextEditingController ctrl, + {bool required = false, int maxLines = 1}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _labelWithStar(label, required: required), + MySpacing.height(8), + TextFormField( + controller: ctrl, + maxLines: maxLines, + decoration: _inputDecoration("Enter $label"), + validator: required + ? (v) => (v == null || v.trim().isEmpty) + ? "$label is required" + : null + : null, + ), + ], + ); + Widget _employeeSelector() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _labelWithStar("Select Assignees", required: true), + MySpacing.height(8), + GestureDetector( + onTap: () async { + final selectedEmployees = + await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => EmployeeSelectionBottomSheet( + multipleSelection: true, + initiallySelected: _selectedEmployees, + ), + ); + + if (selectedEmployees != null) { + setState(() { + _selectedEmployees.assignAll(selectedEmployees); + }); + } + }, + child: AbsorbPointer( + child: TextFormField( + decoration: _inputDecoration("Select Employees"), + controller: TextEditingController( + text: _selectedEmployees.isEmpty + ? "" + : _selectedEmployees + .map((e) => "${e.firstName} ${e.lastName}") + .join(", "), + ), + validator: (v) => _selectedEmployees.isEmpty + ? "Please select employees" + : null, + ), + ), + ), + ], + ); + + Widget _tagInput() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 48, + child: TextField( + controller: controller.tagCtrl, + onSubmitted: (v) { + final value = v.trim(); + if (value.isNotEmpty && + !controller.enteredTags.contains(value)) { + controller.enteredTags.add(value); + } + controller.tagCtrl.clear(); + }, + decoration: _inputDecoration("Start typing to add tags"), + ), + ), + MySpacing.height(8), + Obx(() => Wrap( + spacing: 8, + children: controller.enteredTags + .map((tag) => Chip( + label: Text(tag), + onDeleted: () => controller.enteredTags.remove(tag))) + .toList(), + )), + ], + ); + + + + void _toggleEmployee(EmployeeModel emp) { + final contains = _selectedEmployees.contains(emp); + if (contains) { + _selectedEmployees.remove(emp); + } else { + _selectedEmployees.add(emp); + } + controller.toggleAssignee(emp); + } + + void _handleSubmit() { + if (!(formKey.currentState?.validate() ?? false)) return; + controller.titleCtrl.text = controller.titleCtrl.text.trim(); + controller.descCtrl.text = controller.descCtrl.text.trim(); + controller.createJob(widget.projectId); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Add New Job", + onCancel: () => Get.back(), + onSubmit: _handleSubmit, + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _textField("Title", controller.titleCtrl, required: true), + MySpacing.height(16), + Obx(() { + if (_searchController.text.isNotEmpty) + return const SizedBox.shrink(); + return Wrap( + spacing: 8, + runSpacing: 4, + children: _selectedEmployees + .map( + (emp) => Chip( + label: Text('${emp.firstName} ${emp.lastName}'), + onDeleted: () => _toggleEmployee(emp), + ), + ) + .toList(), + ); + }), + _employeeSelector(), + MySpacing.height(16), + MyText.labelMedium("Tags (Optional)"), + MySpacing.height(8), + _tagInput(), + MySpacing.height(16), + _labelWithStar("Select Date Range", required: true), + MySpacing.height(8), + DateRangePickerWidget( + startDate: controller.startDate, + endDate: controller.dueDate, + startLabel: "Start Date", + endLabel: "Due Date", + onDateRangeSelected: (start, end) { + controller.startDate.value = start ?? DateTime.now(); + controller.dueDate.value = + end ?? DateTime.now().add(const Duration(days: 1)); + }, + ), + MySpacing.height(16), + _textField("Description", controller.descCtrl, + required: true, maxLines: 3), + ], + ), + ), + ); + } +} diff --git a/lib/model/service_project/job_list_model.dart b/lib/model/service_project/job_list_model.dart new file mode 100644 index 0000000..2ddd1ce --- /dev/null +++ b/lib/model/service_project/job_list_model.dart @@ -0,0 +1,237 @@ +class JobResponse { + final bool success; + final String message; + final int statusCode; + final String timestamp; + final JobData? data; + final dynamic errors; + + JobResponse({ + required this.success, + required this.message, + required this.statusCode, + required this.timestamp, + this.data, + this.errors, + }); + + factory JobResponse.fromJson(Map json) { + return JobResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + data: json['data'] != null ? JobData.fromJson(json['data']) : null, + errors: json['errors'], + ); + } +} + +class JobData { + final int currentPage; + final int totalPages; + final int totalEntities; + final List? data; + + JobData({ + required this.currentPage, + required this.totalPages, + required this.totalEntities, + this.data, + }); + + factory JobData.fromJson(Map json) { + return JobData( + currentPage: json['currentPage'] ?? 0, + totalPages: json['totalPages'] ?? 0, + totalEntities: json['totalEntities'] ?? 0, + data: (json['data'] as List?) + ?.map((e) => JobEntity.fromJson(e)) + .toList(), + ); + } +} + +class JobEntity { + final String id; + final String title; + final String description; + final Project project; + final List? assignees; + final Status status; + final String startDate; + final String dueDate; + final bool isActive; + final String createdAt; + final CreatedBy createdBy; + final List? tags; + + JobEntity({ + required this.id, + required this.title, + required this.description, + required this.project, + this.assignees, + required this.status, + required this.startDate, + required this.dueDate, + required this.isActive, + required this.createdAt, + required this.createdBy, + this.tags, + }); + + factory JobEntity.fromJson(Map json) { + return JobEntity( + id: json['id'] ?? '', + title: json['title'] ?? '', + description: json['description'] ?? '', + project: Project.fromJson(json['project']), + assignees: (json['assignees'] as List?) + ?.map((e) => Assignee.fromJson(e)) + .toList(), + status: Status.fromJson(json['status']), + startDate: json['startDate'] ?? '', + dueDate: json['dueDate'] ?? '', + isActive: json['isActive'] ?? false, + createdAt: json['createdAt'] ?? '', + createdBy: CreatedBy.fromJson(json['createdBy']), + tags: (json['tags'] as List?) + ?.map((e) => Tag.fromJson(e)) + .toList(), + ); + } +} + +class Project { + final String id; + final String name; + final String shortName; + final String assignedDate; + final String contactName; + final String contactPhone; + final String contactEmail; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.assignedDate, + required this.contactName, + required this.contactPhone, + required this.contactEmail, + }); + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] ?? '', + name: json['name'] ?? '', + shortName: json['shortName'] ?? '', + assignedDate: json['assignedDate'] ?? '', + contactName: json['contactName'] ?? '', + contactPhone: json['contactPhone'] ?? '', + contactEmail: json['contactEmail'] ?? '', + ); + } +} + +class Assignee { + final String id; + final String firstName; + final String lastName; + final String email; + final String photo; + final String jobRoleId; + final String jobRoleName; + + Assignee({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory Assignee.fromJson(Map json) { + return Assignee( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + email: json['email'] ?? '', + photo: json['photo'] ?? '', + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} + +class Status { + final String id; + final String name; + final String displayName; + + Status({ + required this.id, + required this.name, + required this.displayName, + }); + + factory Status.fromJson(Map json) { + return Status( + id: json['id'] ?? '', + name: json['name'] ?? '', + displayName: json['displayName'] ?? '', + ); + } +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String email; + final String photo; + final String jobRoleId; + final String jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) { + return CreatedBy( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + email: json['email'] ?? '', + photo: json['photo'] ?? '', + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } +} + +class Tag { + final String id; + final String name; + + Tag({ + required this.id, + required this.name, + }); + + factory Tag.fromJson(Map json) { + return Tag( + id: json['id'] ?? '', + name: json['name'] ?? '', + ); + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 6ff69e3..9cbe20d 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -106,6 +106,7 @@ class _DashboardScreenState extends State with UIMixin { MenuItems.directory, MenuItems.finance, MenuItems.documents, + MenuItems.serviceProjects ]; final Map cardMeta = { @@ -123,6 +124,8 @@ class _DashboardScreenState extends State with UIMixin { _DashboardCardMeta(LucideIcons.wallet, contentTheme.info), MenuItems.documents: _DashboardCardMeta(LucideIcons.file_text, contentTheme.info), + MenuItems.serviceProjects: + _DashboardCardMeta(LucideIcons.package, contentTheme.info), }; // Filter only available menus that exist in cardMeta diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index e9f18b9..ad39ef7 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -5,7 +5,10 @@ import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/service_project/service_project_details_screen_controller.dart'; import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/service_project/add_service_project_job_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -19,9 +22,10 @@ class ServiceProjectDetailsScreen extends StatefulWidget { class _ServiceProjectDetailsScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, UIMixin { late final TabController _tabController; late final ServiceProjectDetailsController controller; + final ScrollController _jobScrollController = ScrollController(); @override void initState() { @@ -30,15 +34,32 @@ class _ServiceProjectDetailsScreenState _tabController = TabController(length: 2, vsync: this); controller = Get.put(ServiceProjectDetailsController()); - // Fetch project detail safely after first frame WidgetsBinding.instance.addPostFrameCallback((_) { controller.setProjectId(widget.projectId); }); + + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + setState(() {}); // rebuild to show/hide FAB + if (_tabController.index == 1 && controller.jobList.isEmpty) { + controller.fetchProjectJobs(); + } + } + }); + + _jobScrollController.addListener(() { + if (_tabController.index == 1 && + _jobScrollController.position.pixels >= + _jobScrollController.position.maxScrollExtent - 100) { + controller.fetchMoreJobs(); + } + }); } @override void dispose() { _tabController.dispose(); + _jobScrollController.dispose(); super.dispose(); } @@ -157,7 +178,7 @@ class _ServiceProjectDetailsScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Header + // Header Card Card( elevation: 2, shadowColor: Colors.black12, @@ -292,6 +313,155 @@ class _ServiceProjectDetailsScreenState ); } + Widget _buildJobsTab() { + return Obx(() { + if (controller.isJobLoading.value && controller.jobList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.jobErrorMessage.value.isNotEmpty && + controller.jobList.isEmpty) { + return Center( + child: MyText.bodyMedium(controller.jobErrorMessage.value)); + } + + if (controller.jobList.isEmpty) { + return Center(child: MyText.bodyMedium("No jobs found")); + } + + return ListView.separated( + controller: _jobScrollController, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: controller.jobList.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == controller.jobList.length) { + return controller.hasMoreJobs.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + + final job = controller.jobList[index]; + return Card( + elevation: 3, + shadowColor: Colors.black26, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Job Title + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(6), + + // Job Description + MyText.bodySmall( + job.description.isNotEmpty + ? job.description + : "No description provided", + color: Colors.grey[700], + ), + + // Tags + if (job.tags != null && job.tags!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Wrap( + spacing: 2, + runSpacing: 4, + children: job.tags!.map((tag) { + return Chip( + label: Text( + tag.name, + style: const TextStyle(fontSize: 12), + ), + backgroundColor: Colors.grey[200], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ); + }).toList(), + ), + ), + + MySpacing.height(8), + + // Assignees & Status + Row( + children: [ + if (job.assignees != null && job.assignees!.isNotEmpty) + ...job.assignees!.map((assignee) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: CircleAvatar( + radius: 12, + backgroundImage: assignee.photo.isNotEmpty + ? NetworkImage(assignee.photo) + : null, + child: assignee.photo.isEmpty + ? Text(assignee.firstName[0]) + : null, + ), + ); + }).toList(), + ], + ), + + MySpacing.height(8), + + // Date Row with Status Chip + Row( + children: [ + // Dates (same as existing) + const Icon(Icons.calendar_today_outlined, + size: 14, color: Colors.grey), + MySpacing.width(4), + Text( + "${DateTimeUtils.convertUtcToLocal(job.startDate, format: 'dd MMM yyyy')} to " + "${DateTimeUtils.convertUtcToLocal(job.dueDate, format: 'dd MMM yyyy')}", + style: + const TextStyle(fontSize: 12, color: Colors.grey), + ), + + const Spacer(), + + // Status Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: job.status.name.toLowerCase() == 'completed' + ? Colors.green[100] + : Colors.orange[100], + borderRadius: BorderRadius.circular(5), + ), + child: Text( + job.status.displayName, + style: TextStyle( + fontSize: 12, + color: job.status.name.toLowerCase() == 'completed' + ? Colors.green[800] + : Colors.orange[800], + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -320,7 +490,7 @@ class _ServiceProjectDetailsScreenState mainAxisSize: MainAxisSize.min, children: [ MyText.titleLarge( - 'Service Projects', + 'Service Project Details', fontWeight: 700, color: Colors.black, ), @@ -355,50 +525,69 @@ class _ServiceProjectDetailsScreenState ), ), ), - body: Column( - children: [ - // ---------------- TabBar ---------------- - Container( - color: Colors.white, - child: TabBar( - controller: _tabController, - labelColor: Colors.black, - unselectedLabelColor: Colors.grey, - indicatorColor: Colors.red, - indicatorWeight: 3, - isScrollable: false, - tabs: [ - Tab(child: MyText.bodyMedium("Profile")), - Tab(child: MyText.bodyMedium("Jobs")), - ], - ), - ), - - // ---------------- TabBarView ---------------- - Expanded( - child: Obx(() { - if (controller.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - if (controller.errorMessage.value.isNotEmpty) { - return Center( - child: MyText.bodyMedium(controller.errorMessage.value)); - } - - return TabBarView( + body: SafeArea( + child: Column( + children: [ + // TabBar + Container( + color: Colors.white, + child: TabBar( controller: _tabController, - children: [ - // Profile Tab - _buildProfileTab(), - - // Jobs Tab - empty - Container(color: Colors.white), + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.red, + indicatorWeight: 3, + isScrollable: false, + tabs: [ + Tab(child: MyText.bodyMedium("Profile")), + Tab(child: MyText.bodyMedium("Jobs")), ], - ); - }), - ), - ], + ), + ), + + // TabBarView + Expanded( + child: Obx(() { + if (controller.isLoading.value && + controller.projectDetail.value == null) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.value.isNotEmpty && + controller.projectDetail.value == null) { + return Center( + child: MyText.bodyMedium(controller.errorMessage.value)); + } + + return TabBarView( + controller: _tabController, + children: [ + _buildProfileTab(), + _buildJobsTab(), + ], + ); + }), + ), + ], + ), ), + floatingActionButton: _tabController.index == 1 + ? FloatingActionButton.extended( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => AddServiceProjectJobBottomSheet( + projectId: widget.projectId, + ), + ); + }, + backgroundColor: contentTheme.primary, + icon: const Icon(Icons.add), + label: MyText.bodyMedium("Add Job", color: Colors.white), + ) + : null, + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } } diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index 47311c4..8831369 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -8,6 +8,7 @@ import 'package:marco/controller/service_project/service_project_screen_controll import 'package:marco/model/service_project/service_projects_list_model.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/view/service_project/service_project_details_screen.dart'; +import 'package:marco/controller/project_controller.dart'; class ServiceProjectScreen extends StatefulWidget { const ServiceProjectScreen({super.key}); @@ -211,10 +212,41 @@ class _ServiceProjectScreenState extends State onPressed: () => Get.toNamed('/dashboard'), ), MySpacing.width(8), - MyText.titleLarge( - 'Service Projects', - fontWeight: 700, - color: Colors.black, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Service Projects', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (projectController) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ),