From 1dc68033df54f87d5b57f97d7bcf963deff3977f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 11 Nov 2025 12:48:57 +0530 Subject: [PATCH 01/35] fixed category issue while updating and adding expense --- lib/controller/expense/add_expense_controller.dart | 2 +- lib/helpers/services/api_service.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 75e0500..1264cac 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -454,7 +454,7 @@ class AddExpenseController extends GetxController { return { if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, "projectId": projectsMap[selectedProject.value]!, - "expensesTypeId": type.id, + "expenseCategoryId": type.id, "paymentModeId": selectedPaymentMode.value!.id, "paidById": selectedPaidBy.value!.id, "transactionDate": diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 25a46e5..88c1687 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -1921,7 +1921,7 @@ class ApiService { }) async { final payload = { "projectId": projectId, - "expensesTypeId": expensesTypeId, + "expenseCategoryId": expensesTypeId, "paymentModeId": paymentModeId, "paidById": paidById, "transactionDate": transactionDate.toIso8601String(), From 077dbf35eeec8a6d84af41eb070f7cf11dc025ca Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 11 Nov 2025 14:21:54 +0530 Subject: [PATCH 02/35] added routes --- lib/routes.dart | 14 ++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/routes.dart b/lib/routes.dart index 076d455..03e1a01 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -24,6 +24,8 @@ import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart'; import 'package:marco/view/finance/payment_request_screen.dart'; +import 'package:marco/view/service_project/service_project_details_screen.dart'; +import 'package:marco/view/service_project/service_project_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { @@ -132,6 +134,18 @@ getPageRoute() { page: () => AdvancePaymentScreen(), middlewares: [AuthMiddleware()], ), + + // Service Projects + GetPage( + name: '/dashboard/service-project-details', + page: () => ServiceProjectDetailsScreen(), + middlewares: [AuthMiddleware()], + ), + GetPage( + name: '/dashboard/service-projects', + page: () => ServiceProjectScreen(), + middlewares: [AuthMiddleware()], + ), ]; return routes .map((e) => GetPage( diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index aff2670..b8c1402 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -32,6 +32,7 @@ class DashboardScreen extends StatefulWidget { static const String directoryMainPageRoute = "/dashboard/directory-main-page"; static const String financeMainPageRoute = "/dashboard/finance"; static const String documentMainPageRoute = "/dashboard/document-main-page"; + static const String serviceprojectsRoute = "/dashboard/service-projects"; @override State createState() => _DashboardScreenState(); @@ -253,6 +254,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.financeMainPageRoute), _StatItem(LucideIcons.file_text, "Documents", contentTheme.info, DashboardScreen.documentMainPageRoute), + _StatItem(LucideIcons.briefcase, "Service Projects", contentTheme.info, + DashboardScreen.serviceprojectsRoute), ]; // Safe menu check function to avoid exceptions @@ -282,7 +285,9 @@ class _DashboardScreenState extends State with UIMixin { runSpacing: 6, alignment: WrapAlignment.start, children: stats - .where((stat) => _isMenuAllowed(stat.title)) + .where((stat) => + stat.title == "Service Projects" || + _isMenuAllowed(stat.title)) .map((stat) => _buildStatCard(stat, isProjectSelected, cardWidth)) .toList(), From d2c7f92a0252ef98d96b88899543a1d6ae7ed7fb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 11 Nov 2025 14:21:54 +0530 Subject: [PATCH 03/35] added routes --- lib/routes.dart | 14 ++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/routes.dart b/lib/routes.dart index 076d455..03e1a01 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -24,6 +24,8 @@ import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart'; import 'package:marco/view/finance/payment_request_screen.dart'; +import 'package:marco/view/service_project/service_project_details_screen.dart'; +import 'package:marco/view/service_project/service_project_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { @@ -132,6 +134,18 @@ getPageRoute() { page: () => AdvancePaymentScreen(), middlewares: [AuthMiddleware()], ), + + // Service Projects + GetPage( + name: '/dashboard/service-project-details', + page: () => ServiceProjectDetailsScreen(), + middlewares: [AuthMiddleware()], + ), + GetPage( + name: '/dashboard/service-projects', + page: () => ServiceProjectScreen(), + middlewares: [AuthMiddleware()], + ), ]; return routes .map((e) => GetPage( diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index aff2670..b8c1402 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -32,6 +32,7 @@ class DashboardScreen extends StatefulWidget { static const String directoryMainPageRoute = "/dashboard/directory-main-page"; static const String financeMainPageRoute = "/dashboard/finance"; static const String documentMainPageRoute = "/dashboard/document-main-page"; + static const String serviceprojectsRoute = "/dashboard/service-projects"; @override State createState() => _DashboardScreenState(); @@ -253,6 +254,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.financeMainPageRoute), _StatItem(LucideIcons.file_text, "Documents", contentTheme.info, DashboardScreen.documentMainPageRoute), + _StatItem(LucideIcons.briefcase, "Service Projects", contentTheme.info, + DashboardScreen.serviceprojectsRoute), ]; // Safe menu check function to avoid exceptions @@ -282,7 +285,9 @@ class _DashboardScreenState extends State with UIMixin { runSpacing: 6, alignment: WrapAlignment.start, children: stats - .where((stat) => _isMenuAllowed(stat.title)) + .where((stat) => + stat.title == "Service Projects" || + _isMenuAllowed(stat.title)) .map((stat) => _buildStatCard(stat, isProjectSelected, cardWidth)) .toList(), From 7f756f3d4ccc04b47b07807427ca4946623f1c74 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 04/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 10 +- lib/helpers/services/api_service.dart | 54 ++ .../employees/employee_details_model.dart | 4 +- .../employees/employee_detail_screen.dart | 60 ++ lib/view/employees/employees_screen.dart | 167 ++++-- .../manage_reporting_bottom_sheet.dart | 557 ++++++++++++++++++ lib/view/finance/advance_payment_screen.dart | 5 - 7 files changed, 811 insertions(+), 46 deletions(-) create mode 100644 lib/view/employees/manage_reporting_bottom_sheet.dart diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index de12e95..9bf0b27 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,7 @@ 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://devapi.marcoaiot.com/api"; + static const String baseUrl = "https://devapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = @@ -127,4 +127,10 @@ class ApiEndpoints { static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAdvancePayments = '/Expense/get/transactions'; + + // Organization Hierarchy endpoints + static const String getOrganizationHierarchyList = + "/organization/hierarchy/list"; + static const String manageOrganizationHierarchy = + "/organization/hierarchy/manage"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 25a46e5..51649fc 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -655,6 +655,60 @@ class ApiService { } } + /// Fetch hierarchy list for an employee + static Future?> 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> with objects like: + /// { "reportToId": "", "isPrimary": true, "isActive": true } + static Future manageOrganizationHierarchy({ + required String employeeId, + required List> 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 static Future getMasterCurrenciesApi() async { const endpoint = ApiEndpoints.getMasterCurrencies; diff --git a/lib/model/employees/employee_details_model.dart b/lib/model/employees/employee_details_model.dart index b5c22f4..e999836 100644 --- a/lib/model/employees/employee_details_model.dart +++ b/lib/model/employees/employee_details_model.dart @@ -22,7 +22,9 @@ class EmployeeDetailsModel { final bool hasApplicationAccess; final String? organizationId; final String? aadharNumber; - final String? panNumber; + final String? panNumber; + + EmployeeDetailsModel({ required this.id, required this.firstName, diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 7e72066..79a8fb2 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -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/helpers/utils/mixins/ui_mixin.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'; class EmployeeDetailPage extends StatefulWidget { final String employeeId; @@ -282,6 +284,64 @@ class _EmployeeDetailPageState extends State with UIMixin { ), 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 _buildSectionCard( title: 'Contact Information', diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index dea9f7b..667e91e 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -64,13 +65,17 @@ class _EmployeesScreenState extends State with UIMixin { final searchQuery = query.toLowerCase(); final filtered = query.isEmpty ? List.from(employees) - : employees.where((e) => - e.name.toLowerCase().contains(searchQuery) || - e.email.toLowerCase().contains(searchQuery) || - e.phoneNumber.toLowerCase().contains(searchQuery) || - e.jobRole.toLowerCase().contains(searchQuery), - ).toList(); - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + : employees + .where( + (e) => + e.name.toLowerCase().contains(searchQuery) || + e.email.toLowerCase().contains(searchQuery) || + e.phoneNumber.toLowerCase().contains(searchQuery) || + e.jobRole.toLowerCase().contains(searchQuery), + ) + .toList(); + filtered + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); _filteredEmployees.assignAll(filtered); } @@ -106,7 +111,6 @@ class _EmployeesScreenState extends State with UIMixin { await _refreshEmployees(); } - @override Widget build(BuildContext context) { return Scaffold( @@ -160,7 +164,8 @@ class _EmployeesScreenState extends State with UIMixin { child: Row( children: [ 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'), ), MySpacing.width(8), @@ -206,7 +211,8 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildFloatingActionButton() { return Obx(() { 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(); return InkWell( @@ -218,7 +224,8 @@ class _EmployeesScreenState extends State with UIMixin { color: contentTheme.primary, borderRadius: BorderRadius.circular(28), 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( @@ -235,33 +242,116 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildSearchField() { - return SizedBox( - height: 36, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13, height: 1.2), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - hintText: 'Search employees...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300, width: 1), + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // Search field + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13, height: 1.2), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + 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 - ? GestureDetector( - onTap: () { - _searchController.clear(); - _filterEmployees(''); - }, - child: const Icon(Icons.close, size: 18, color: Colors.grey), - ) - : null, - ), - onChanged: (_) => _filterEmployees(_searchController.text), + MySpacing.width(10), + + // Three dots menu (Manage Reporting) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + itemBuilder: (context) { + List> menuItems = []; + + // Section: Actions + menuItems.add( + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.grey), + ), + ), + ); + + // Manage Reporting option + menuItems.add( + PopupMenuItem( + 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 with UIMixin { return Padding( padding: const EdgeInsets.only(top: 60), 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]), ), ); } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart new file mode 100644 index 0000000..3eb5dd4 --- /dev/null +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -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 createState() => + _ManageReportingBottomSheetState(); +} + +class _ManageReportingBottomSheetState + extends State { + final EmployeesScreenController _employeeController = Get.find(); + final TextEditingController _primaryController = TextEditingController(); + final TextEditingController _secondaryController = TextEditingController(); + + final RxList _filteredPrimary = [].obs; + final RxList _filteredSecondary = [].obs; + final RxList _selectedPrimary = [].obs; + final RxList _selectedSecondary = [].obs; + + final TextEditingController _selectEmployeeController = + TextEditingController(); + final RxList _filteredEmployees = [].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 + ? [] + : 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 + ? [] + : 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 _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 _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> 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 filteredList, + required RxList 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(); + }), + ), + ), + ], + ); + } +} diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index 96d14d3..2c1fe40 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State ), ), ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: () {}, - ), ], ), ); From 340d0b8a1ed34af17dd8f02e186524039fc1beec Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 05/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 10 +- lib/helpers/services/api_service.dart | 54 ++ .../employees/employee_details_model.dart | 4 +- .../employees/employee_detail_screen.dart | 60 ++ lib/view/employees/employees_screen.dart | 167 ++++-- .../manage_reporting_bottom_sheet.dart | 557 ++++++++++++++++++ lib/view/finance/advance_payment_screen.dart | 5 - 7 files changed, 811 insertions(+), 46 deletions(-) create mode 100644 lib/view/employees/manage_reporting_bottom_sheet.dart diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index de12e95..9bf0b27 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,7 @@ 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://devapi.marcoaiot.com/api"; + static const String baseUrl = "https://devapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = @@ -127,4 +127,10 @@ class ApiEndpoints { static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAdvancePayments = '/Expense/get/transactions'; + + // Organization Hierarchy endpoints + static const String getOrganizationHierarchyList = + "/organization/hierarchy/list"; + static const String manageOrganizationHierarchy = + "/organization/hierarchy/manage"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d0d942d..b22e261 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -655,6 +655,60 @@ class ApiService { } } + /// Fetch hierarchy list for an employee + static Future?> 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> with objects like: + /// { "reportToId": "", "isPrimary": true, "isActive": true } + static Future manageOrganizationHierarchy({ + required String employeeId, + required List> 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 static Future getMasterCurrenciesApi() async { const endpoint = ApiEndpoints.getMasterCurrencies; diff --git a/lib/model/employees/employee_details_model.dart b/lib/model/employees/employee_details_model.dart index b5c22f4..e999836 100644 --- a/lib/model/employees/employee_details_model.dart +++ b/lib/model/employees/employee_details_model.dart @@ -22,7 +22,9 @@ class EmployeeDetailsModel { final bool hasApplicationAccess; final String? organizationId; final String? aadharNumber; - final String? panNumber; + final String? panNumber; + + EmployeeDetailsModel({ required this.id, required this.firstName, diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 904d101..217de6c 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -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/helpers/utils/mixins/ui_mixin.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'; class EmployeeDetailPage extends StatefulWidget { @@ -283,6 +285,64 @@ class _EmployeeDetailPageState extends State with UIMixin { ), 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 _buildSectionCard( title: 'Contact Information', diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 5a6d925..db1f821 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -64,13 +65,17 @@ class _EmployeesScreenState extends State with UIMixin { final searchQuery = query.toLowerCase(); final filtered = query.isEmpty ? List.from(employees) - : employees.where((e) => - e.name.toLowerCase().contains(searchQuery) || - e.email.toLowerCase().contains(searchQuery) || - e.phoneNumber.toLowerCase().contains(searchQuery) || - e.jobRole.toLowerCase().contains(searchQuery), - ).toList(); - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + : employees + .where( + (e) => + e.name.toLowerCase().contains(searchQuery) || + e.email.toLowerCase().contains(searchQuery) || + e.phoneNumber.toLowerCase().contains(searchQuery) || + e.jobRole.toLowerCase().contains(searchQuery), + ) + .toList(); + filtered + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); _filteredEmployees.assignAll(filtered); } @@ -106,7 +111,6 @@ class _EmployeesScreenState extends State with UIMixin { await _refreshEmployees(); } - @override Widget build(BuildContext context) { return Scaffold( @@ -160,7 +164,8 @@ class _EmployeesScreenState extends State with UIMixin { child: Row( children: [ 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'), ), MySpacing.width(8), @@ -206,7 +211,8 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildFloatingActionButton() { return Obx(() { 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(); return InkWell( @@ -218,7 +224,8 @@ class _EmployeesScreenState extends State with UIMixin { color: contentTheme.primary, borderRadius: BorderRadius.circular(28), 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( @@ -235,33 +242,116 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildSearchField() { - return SizedBox( - height: 36, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13, height: 1.2), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - hintText: 'Search employees...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300, width: 1), + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // Search field + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13, height: 1.2), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + 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 - ? GestureDetector( - onTap: () { - _searchController.clear(); - _filterEmployees(''); - }, - child: const Icon(Icons.close, size: 18, color: Colors.grey), - ) - : null, - ), - onChanged: (_) => _filterEmployees(_searchController.text), + MySpacing.width(10), + + // Three dots menu (Manage Reporting) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + itemBuilder: (context) { + List> menuItems = []; + + // Section: Actions + menuItems.add( + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.grey), + ), + ), + ); + + // Manage Reporting option + menuItems.add( + PopupMenuItem( + 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 with UIMixin { return Padding( padding: const EdgeInsets.only(top: 60), 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]), ), ); } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart new file mode 100644 index 0000000..3eb5dd4 --- /dev/null +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -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 createState() => + _ManageReportingBottomSheetState(); +} + +class _ManageReportingBottomSheetState + extends State { + final EmployeesScreenController _employeeController = Get.find(); + final TextEditingController _primaryController = TextEditingController(); + final TextEditingController _secondaryController = TextEditingController(); + + final RxList _filteredPrimary = [].obs; + final RxList _filteredSecondary = [].obs; + final RxList _selectedPrimary = [].obs; + final RxList _selectedSecondary = [].obs; + + final TextEditingController _selectEmployeeController = + TextEditingController(); + final RxList _filteredEmployees = [].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 + ? [] + : 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 + ? [] + : 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 _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 _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> 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 filteredList, + required RxList 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(); + }), + ), + ), + ], + ); + } +} diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index 96d14d3..2c1fe40 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State ), ), ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: () {}, - ), ], ), ); From cb009119834364b677ef36132cb0521007b19c69 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 12 Nov 2025 15:36:10 +0530 Subject: [PATCH 06/35] added service projet screen --- ...ice_project_details_screen_controller.dart | 53 +++ .../service_project_screen_controller.dart | 36 ++ lib/helpers/services/api_endpoints.dart | 8 +- lib/helpers/services/api_service.dart | 76 +++- .../service_projects_details_model.dart | 241 +++++++++++ .../service_projects_list_model.dart | 127 ++++++ lib/routes.dart | 6 - .../service_project_details_screen.dart | 299 +++++++++++++- .../service_project_screen.dart | 386 ++++++++---------- .../taskPlanning/daily_task_planning.dart | 4 +- 10 files changed, 991 insertions(+), 245 deletions(-) create mode 100644 lib/controller/service_project/service_project_details_screen_controller.dart create mode 100644 lib/controller/service_project/service_project_screen_controller.dart create mode 100644 lib/model/service_project/service_projects_details_model.dart create mode 100644 lib/model/service_project/service_projects_list_model.dart diff --git a/lib/controller/service_project/service_project_details_screen_controller.dart b/lib/controller/service_project/service_project_details_screen_controller.dart new file mode 100644 index 0000000..65b9f38 --- /dev/null +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -0,0 +1,53 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/service_project/service_projects_details_model.dart'; + +class ServiceProjectDetailsController extends GetxController { + // Selected project id + var projectId = ''.obs; + + // Project details + var projectDetail = Rxn(); + + // Loading state + var isLoading = false.obs; + + // Error message + var errorMessage = ''.obs; + + /// Set project id and fetch its details + void setProjectId(String id) { + projectId.value = id; + fetchProjectDetail(); + } + + /// Fetch project detail from API + Future fetchProjectDetail() async { + if (projectId.value.isEmpty) { + errorMessage.value = "Invalid project ID"; + return; + } + + isLoading.value = true; + errorMessage.value = ''; + + try { + 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"; + } + } catch (e) { + errorMessage.value = "Error: $e"; + } finally { + isLoading.value = false; + } + } + + /// Refresh project details manually + Future refresh() async { + await fetchProjectDetail(); + } +} diff --git a/lib/controller/service_project/service_project_screen_controller.dart b/lib/controller/service_project/service_project_screen_controller.dart new file mode 100644 index 0000000..ae0b423 --- /dev/null +++ b/lib/controller/service_project/service_project_screen_controller.dart @@ -0,0 +1,36 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/service_project/service_projects_list_model.dart'; + +class ServiceProjectController extends GetxController { + var projects = [].obs; + var isLoading = false.obs; + var searchQuery = ''.obs; + + RxList get filteredProjects { + if (searchQuery.value.isEmpty) return projects; + return projects + .where((p) => + p.name.toLowerCase().contains(searchQuery.value.toLowerCase()) || + p.contactPerson.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList() + .obs; + } + + Future fetchProjects({int pageNumber = 1, int pageSize = 20}) async { + try { + isLoading.value = true; + final result = await ApiService.getServiceProjectsListApi( + pageNumber: pageNumber, pageSize: pageSize); + if (result != null && result.data != null) { + projects.assignAll(result.data!.data ?? []); + } + } finally { + isLoading.value = false; + } + } + + void updateSearch(String query) { + searchQuery.value = query; + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index de12e95..e73e150 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,9 @@ 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://devapi.marcoaiot.com/api"; + static const String baseUrl = "https://mapi.marcoaiot.com/api"; + static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = @@ -127,4 +129,8 @@ class ApiEndpoints { static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAdvancePayments = '/Expense/get/transactions'; + + // Service Project Module API Endpoints + static const String getServiceProjectsList = "/serviceproject/list"; + static const String getServiceProjectDetail = "/serviceproject/details"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d0d942d..48e5179 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -33,6 +33,8 @@ import 'package:marco/model/finance/payment_request_list_model.dart'; import 'package:marco/model/finance/payment_request_filter.dart'; 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'; class ApiService { static const bool enableLogs = true; @@ -302,6 +304,76 @@ 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"); + + 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({ + int pageNumber = 1, + int pageSize = 20, + }) async { + const endpoint = ApiEndpoints.getServiceProjectsList; + logSafe("Fetching Service Project List"); + + try { + final queryParams = { + 'pageNumber': pageNumber.toString(), + 'pageSize': pageSize.toString(), + }; + + final response = await _getRequest(endpoint, queryParams: queryParams); + + if (response == null) { + logSafe("Service Project List request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project List", + ); + + if (jsonResponse != null) { + return ServiceProjectListModel.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectsListApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Edit Expense Payment Request static Future editExpensePaymentRequestApi({ required String id, @@ -1707,8 +1779,8 @@ class ApiService { String? reimbursedById, double? baseAmount, double? taxAmount, - double? tdsPercent, - double? netPayable, + double? tdsPercent, + double? netPayable, }) async { final Map payload = { "expenseId": expenseId, diff --git a/lib/model/service_project/service_projects_details_model.dart b/lib/model/service_project/service_projects_details_model.dart new file mode 100644 index 0000000..47cad69 --- /dev/null +++ b/lib/model/service_project/service_projects_details_model.dart @@ -0,0 +1,241 @@ +class ServiceProjectDetailModel { + final bool success; + final String message; + final ProjectDetail? data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ServiceProjectDetailModel({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ServiceProjectDetailModel.fromJson(Map json) { + return ServiceProjectDetailModel( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null ? ProjectDetail.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ProjectDetail { + final String id; + final String name; + final String shortName; + final String address; + final DateTime assignedDate; + final Status? status; + final Client? client; + final List? services; + final int numberOfJobs; + final String contactName; + final String contactPhone; + final String contactEmail; + final DateTime createdAt; + final User? createdBy; + final DateTime updatedAt; + final User? updatedBy; + + ProjectDetail({ + required this.id, + required this.name, + required this.shortName, + required this.address, + required this.assignedDate, + this.status, + this.client, + this.services, + required this.numberOfJobs, + required this.contactName, + required this.contactPhone, + required this.contactEmail, + required this.createdAt, + this.createdBy, + required this.updatedAt, + this.updatedBy, + }); + + factory ProjectDetail.fromJson(Map json) { + return ProjectDetail( + id: json['id'] ?? '', + name: json['name'] ?? '', + shortName: json['shortName'] ?? '', + address: json['address'] ?? '', + assignedDate: DateTime.parse(json['assignedDate'] ?? DateTime.now().toIso8601String()), + status: json['status'] != null ? Status.fromJson(json['status']) : null, + client: json['client'] != null ? Client.fromJson(json['client']) : null, + services: json['services'] != null + ? List.from(json['services'].map((x) => Service.fromJson(x))) + : [], + numberOfJobs: json['numberOfJobs'] ?? 0, + contactName: json['contactName'] ?? '', + contactPhone: json['contactPhone'] ?? '', + contactEmail: json['contactEmail'] ?? '', + createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null, + updatedAt: DateTime.parse(json['updatedAt'] ?? DateTime.now().toIso8601String()), + updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'shortName': shortName, + 'address': address, + 'assignedDate': assignedDate.toIso8601String(), + 'status': status?.toJson(), + 'client': client?.toJson(), + 'services': services?.map((x) => x.toJson()).toList(), + 'numberOfJobs': numberOfJobs, + 'contactName': contactName, + 'contactPhone': contactPhone, + 'contactEmail': contactEmail, + 'createdAt': createdAt.toIso8601String(), + 'createdBy': createdBy?.toJson(), + 'updatedAt': updatedAt.toIso8601String(), + 'updatedBy': updatedBy?.toJson(), + }; +} + +class Status { + final String id; + final String status; + + Status({required this.id, required this.status}); + + factory Status.fromJson(Map json) => + Status(id: json['id'] ?? '', status: json['status'] ?? ''); + + Map toJson() => {'id': id, 'status': status}; +} + +class Client { + final String id; + final String name; + final String? email; + final String? contactPerson; + final String? address; + final String? contactNumber; + final int? sprid; + + Client({ + required this.id, + required this.name, + this.email, + this.contactPerson, + this.address, + this.contactNumber, + this.sprid, + }); + + factory Client.fromJson(Map json) => Client( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'], + contactPerson: json['contactPerson'], + address: json['address'], + contactNumber: json['contactNumber'], + sprid: json['sprid'], + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'email': email, + 'contactPerson': contactPerson, + 'address': address, + 'contactNumber': contactNumber, + 'sprid': sprid, + }; +} + +class Service { + final String id; + final String name; + final String? description; + final bool isSystem; + final bool isActive; + + Service({ + required this.id, + required this.name, + this.description, + required this.isSystem, + required this.isActive, + }); + + factory Service.fromJson(Map json) => Service( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'], + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'isSystem': isSystem, + 'isActive': isActive, + }; +} + +class User { + final String id; + final String firstName; + final String lastName; + final String email; + final String? photo; + final String? jobRoleId; + final String? jobRoleName; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + this.photo, + this.jobRoleId, + this.jobRoleName, + }); + + factory User.fromJson(Map json) => User( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + email: json['email'] ?? '', + photo: json['photo'], + jobRoleId: json['jobRoleId'], + jobRoleName: json['jobRoleName'], + ); + + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; +} diff --git a/lib/model/service_project/service_projects_list_model.dart b/lib/model/service_project/service_projects_list_model.dart new file mode 100644 index 0000000..5d9da34 --- /dev/null +++ b/lib/model/service_project/service_projects_list_model.dart @@ -0,0 +1,127 @@ +class ServiceProjectListModel { + final bool success; + final String message; + final ProjectData? data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ServiceProjectListModel({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ServiceProjectListModel.fromJson(Map json) { + return ServiceProjectListModel( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null ? ProjectData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ProjectData { + final int currentPage; + final int totalPages; + final int totalEntities; + final List? data; + + ProjectData({ + required this.currentPage, + required this.totalPages, + required this.totalEntities, + this.data, + }); + + factory ProjectData.fromJson(Map json) { + return ProjectData( + currentPage: json['currentPage'] ?? 1, + totalPages: json['totalPages'] ?? 1, + totalEntities: json['totalEntites'] ?? 0, + data: json['data'] != null + ? List.from(json['data'].map((x) => ProjectItem.fromJson(x))) + : [], + ); + } + + Map toJson() => { + 'currentPage': currentPage, + 'totalPages': totalPages, + 'totalEntites': totalEntities, + 'data': data?.map((x) => x.toJson()).toList(), + }; +} + +class ProjectItem { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final DateTime startDate; + final DateTime endDate; + final String projectStatusId; + final int teamSize; + final double completedWork; + final double plannedWork; + + ProjectItem({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + required this.teamSize, + required this.completedWork, + required this.plannedWork, + }); + + factory ProjectItem.fromJson(Map json) { + return ProjectItem( + id: json['id'] ?? '', + name: json['name'] ?? '', + shortName: json['shortName'] ?? '', + projectAddress: json['projectAddress'] ?? '', + contactPerson: json['contactPerson'] ?? '', + startDate: DateTime.parse(json['startDate'] ?? DateTime.now().toIso8601String()), + endDate: DateTime.parse(json['endDate'] ?? DateTime.now().toIso8601String()), + projectStatusId: json['projectStatusId'] ?? '', + teamSize: json['teamSize'] ?? 0, + completedWork: (json['completedWork']?.toDouble() ?? 0.0), + plannedWork: (json['plannedWork']?.toDouble() ?? 0.0), + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'shortName': shortName, + 'projectAddress': projectAddress, + 'contactPerson': contactPerson, + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + 'projectStatusId': projectStatusId, + 'teamSize': teamSize, + 'completedWork': completedWork, + 'plannedWork': plannedWork, + }; +} diff --git a/lib/routes.dart b/lib/routes.dart index 03e1a01..bfef6ea 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -24,7 +24,6 @@ import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart'; import 'package:marco/view/finance/payment_request_screen.dart'; -import 'package:marco/view/service_project/service_project_details_screen.dart'; import 'package:marco/view/service_project/service_project_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -136,11 +135,6 @@ getPageRoute() { ), // Service Projects - GetPage( - name: '/dashboard/service-project-details', - page: () => ServiceProjectDetailsScreen(), - middlewares: [AuthMiddleware()], - ), GetPage( name: '/dashboard/service-projects', page: () => ServiceProjectScreen(), diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index 491ea8f..cea61be 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; 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/widgets/my_text.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { - const ServiceProjectDetailsScreen({super.key}); + final String projectId; + + const ServiceProjectDetailsScreen({super.key, required this.projectId}); @override State createState() => @@ -13,13 +18,17 @@ class ServiceProjectDetailsScreen extends StatefulWidget { } class _ServiceProjectDetailsScreenState - extends State with SingleTickerProviderStateMixin { + extends State + with SingleTickerProviderStateMixin { late TabController _tabController; + final ServiceProjectDetailsController controller = + Get.put(ServiceProjectDetailsController()); @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); + controller.setProjectId(widget.projectId); } @override @@ -28,6 +37,263 @@ class _ServiceProjectDetailsScreenState super.dispose(); } + // ---------------- Helper Widgets ---------------- + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + VoidCallback? onTap, + VoidCallback? onLongPress, + bool isActionable = false, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: InkWell( + onTap: isActionable && value != 'NA' ? onTap : null, + onLongPress: isActionable && value != 'NA' ? onLongPress : null, + borderRadius: BorderRadius.circular(5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.redAccent.withOpacity(0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Icon(icon, size: 20, color: Colors.redAccent), + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + MySpacing.height(4), + Text( + value, + style: TextStyle( + fontSize: 15, + color: isActionable && value != 'NA' + ? Colors.redAccent + : Colors.black87, + fontWeight: FontWeight.w500, + decoration: isActionable && value != 'NA' + ? TextDecoration.underline + : TextDecoration.none, + ), + ), + ], + ), + ), + if (isActionable && value != 'NA') + Icon(Icons.chevron_right, color: Colors.grey[400], size: 20), + ], + ), + ), + ); + } + + Widget _buildSectionCard({ + required String title, + required IconData titleIcon, + required List children, + }) { + return Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(titleIcon, size: 20, color: Colors.redAccent), + MySpacing.width(8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87), + ), + ], + ), + MySpacing.height(8), + const Divider(), + ...children, + ], + ), + ), + ); + } + + String _formatDate(DateTime? date) { + if (date == null) return 'NA'; + try { + return DateFormat('d/M/yyyy').format(date); + } catch (_) { + return 'NA'; + } + } + + Widget _buildProfileTab() { + final project = controller.projectDetail.value; + if (project == null) return const Center(child: Text("No project data")); + + return Padding( + padding: MySpacing.all(12), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Header + Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 45, color: Colors.redAccent), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium(project.name, fontWeight: 700), + MySpacing.height(6), + MyText.bodySmall( + project.client?.name ?? 'N/A', fontWeight: 500), + ], + ), + ), + ], + ), + ), + ), + MySpacing.height(16), + + // Project Information + _buildSectionCard( + title: 'Project Information', + titleIcon: Icons.info_outline, + children: [ + _buildDetailRow( + icon: Icons.calendar_today_outlined, + label: 'Assigned Date', + value: _formatDate(project.assignedDate), + ), + _buildDetailRow( + icon: Icons.location_on_outlined, + label: 'Address', + value: project.address, + ), + _buildDetailRow( + icon: Icons.people_outline, + label: 'Contact Name', + value: project.contactName, + ), + _buildDetailRow( + icon: Icons.phone_outlined, + label: 'Contact Phone', + value: project.contactPhone, + isActionable: true, + onTap: () => + LauncherUtils.launchPhone(project.contactPhone), + onLongPress: () => LauncherUtils.copyToClipboard( + project.contactPhone, + typeLabel: 'Phone'), + ), + _buildDetailRow( + icon: Icons.email_outlined, + label: 'Contact Email', + value: project.contactEmail, + isActionable: true, + onTap: () => + LauncherUtils.launchEmail(project.contactEmail), + onLongPress: () => LauncherUtils.copyToClipboard( + project.contactEmail, + typeLabel: 'Email'), + ), + ], + ), + MySpacing.height(12), + + // Status + if (project.status != null) + _buildSectionCard( + title: 'Status', + titleIcon: Icons.flag_outlined, + children: [ + _buildDetailRow( + icon: Icons.info_outline, + label: 'Status', + value: project.status!.status, + ), + ], + ), + + // Services + if (project.services != null && project.services!.isNotEmpty) + _buildSectionCard( + title: 'Services', + titleIcon: Icons.miscellaneous_services_outlined, + children: project.services!.map((service) { + return _buildDetailRow( + icon: Icons.build_outlined, + label: service.name, + value: service.description ?? '-', + ); + }).toList(), + ), + + MySpacing.height(12), + + // Client Section + if (project.client != null) + _buildSectionCard( + title: 'Client Information', + titleIcon: Icons.business_outlined, + children: [ + _buildDetailRow( + icon: Icons.person_outline, + label: 'Client Name', + value: project.client!.name, + ), + _buildDetailRow( + icon: Icons.phone_outlined, + label: 'Client Phone', + value: project.client!.contactNumber ?? 'NA', + isActionable: true, + onTap: () => LauncherUtils.launchPhone( + project.client!.contactNumber ?? ''), + onLongPress: () => LauncherUtils.copyToClipboard( + project.client!.contactNumber ?? '', + typeLabel: 'Phone'), + ), + ], + ), + + MySpacing.height(40), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -47,7 +313,7 @@ class _ServiceProjectDetailsScreenState IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), + onPressed: () => Get.toNamed('/dashboard/service-projects'), ), MySpacing.width(8), Expanded( @@ -101,6 +367,8 @@ class _ServiceProjectDetailsScreenState labelColor: Colors.black, unselectedLabelColor: Colors.grey, indicatorColor: Colors.red, + indicatorWeight: 3, + isScrollable: false, tabs: const [ Tab(text: "Profile"), Tab(text: "Jobs"), @@ -110,12 +378,25 @@ class _ServiceProjectDetailsScreenState // ---------------- TabBarView ---------------- Expanded( - child: TabBarView( - controller: _tabController, - children: const [ - // Add your tab content here later - ], - ), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.value.isNotEmpty) { + return Center(child: Text(controller.errorMessage.value)); + } + + return TabBarView( + controller: _tabController, + children: [ + // Profile Tab + _buildProfileTab(), + + // Jobs Tab - empty + Container(color: Colors.white), + ], + ); + }), ), ], ), diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index aa56f80..c4f6b7f 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.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_refresh_indicator.dart'; -import 'package:marco/helpers/widgets/avatar.dart'; -import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/service_project/service_project_screen_controller.dart'; +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'; class ServiceProjectScreen extends StatefulWidget { const ServiceProjectScreen({super.key}); @@ -18,190 +19,104 @@ class ServiceProjectScreen extends StatefulWidget { class _ServiceProjectScreenState extends State with UIMixin { final TextEditingController searchController = TextEditingController(); - final RxList> allProjects = >[].obs; - final RxList> filteredProjects = - >[].obs; + final ServiceProjectController controller = + Get.put(ServiceProjectController()); @override void initState() { super.initState(); - _loadProjects(); - } - - void _loadProjects() { - final staticProjects = [ - { - "name": "Website Redesign", - "description": "Revamping the corporate website UI/UX", - "status": "In Progress", - "manager": "John Doe", - "email": "john@company.com", - "phone": "+91 9876543210", - "tags": ["UI", "Frontend", "High Priority"] - }, - { - "name": "Mobile App Development", - "description": "Cross-platform mobile app for customers", - "status": "Completed", - "manager": "Priya Sharma", - "email": "priya@company.com", - "phone": "+91 9812345678", - "tags": ["Flutter", "Backend"] - }, - { - "name": "Data Migration", - "description": "Migrating legacy data to AWS", - "status": "Pending", - "manager": "Arun Mehta", - "email": "arun@company.com", - "phone": "+91 9999988888", - "tags": ["Database", "Cloud"] - }, - ]; - allProjects.assignAll(staticProjects); - filteredProjects.assignAll(staticProjects); - } - - void _filterProjects(String query) { - if (query.isEmpty) { - filteredProjects.assignAll(allProjects); - } else { - filteredProjects.assignAll(allProjects - .where((p) => - p["name"].toLowerCase().contains(query.toLowerCase()) || - p["manager"].toLowerCase().contains(query.toLowerCase())) - .toList()); - } + controller.fetchProjects(); + searchController.addListener(() { + controller.updateSearch(searchController.text); + }); } Future _refreshProjects() async { - await Future.delayed(const Duration(seconds: 1)); + await controller.fetchProjects(); } - Widget _buildProjectCard(Map project) { + Widget _buildProjectCard(ProjectItem project) { return Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), - elevation: 3, - shadowColor: Colors.grey.withOpacity(0.3), + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + shadowColor: Colors.indigo.withOpacity(0.10), color: Colors.white, child: InkWell( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(14), onTap: () { - // TODO: Navigate to Project Details screen + // Navigate to ServiceProjectDetailsScreen + Get.to( + () => ServiceProjectDetailsScreen(projectId: project.id), + ); }, child: Padding( - padding: const EdgeInsets.all(12), - child: Row( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Avatar( - firstName: project["name"].split(" ").first, - lastName: project["name"].split(" ").length > 1 - ? project["name"].split(" ").last - : "", - size: 40, - ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall(project["name"], - fontWeight: 600, overflow: TextOverflow.ellipsis), - MyText.bodySmall(project["description"], - color: Colors.grey[700], - overflow: TextOverflow.ellipsis), - MySpacing.height(6), - Row( - children: [ - Icon(Icons.person_outline, - size: 16, color: Colors.indigo), - MySpacing.width(4), - MyText.labelSmall(project["manager"], - color: Colors.indigo), - ], - ), - MySpacing.height(4), - Row( - children: [ - Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - Expanded( - child: MyText.labelSmall( - project["email"], - color: Colors.indigo, - decoration: TextDecoration.underline, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - MySpacing.height(4), - Row( - children: [ - Icon(Icons.phone_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - Expanded( - child: MyText.labelSmall( - project["phone"], - color: Colors.indigo, - decoration: TextDecoration.underline, - overflow: TextOverflow.ellipsis, - ), - ), - MySpacing.width(8), - const FaIcon(FontAwesomeIcons.whatsapp, - color: Colors.green, size: 20), - ], - ), - MySpacing.height(6), - Wrap( - spacing: 6, - runSpacing: 2, - children: (project["tags"] as List) - .map((tag) => Chip( - label: Text(tag), - backgroundColor: Colors.indigo.shade50, - labelStyle: const TextStyle( - color: Colors.indigo, fontSize: 12), - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - )) - .toList(), - ), - ], - ), - ), - Column( + /// Header Row: Avatar | Name & Tags | Status + Row( children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: project["status"] == "Completed" - ? Colors.green.shade100 - : project["status"] == "In Progress" - ? Colors.orange.shade100 - : Colors.red.shade100, - borderRadius: BorderRadius.circular(12), - ), - child: MyText.labelSmall( - project["status"], - fontWeight: 600, - color: project["status"] == "Completed" - ? Colors.green - : project["status"] == "In Progress" - ? Colors.orange - : Colors.red, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + project.name, + fontWeight: 800, + ), + MySpacing.height(2), + Row( + children: [ + if (project.shortName.isNotEmpty) + _buildTag(project.shortName), + if (project.shortName.isNotEmpty) + MySpacing.width(6), + Icon(Icons.location_on, + size: 15, color: Colors.deepOrange.shade400), + MySpacing.width(2), + Flexible( + child: MyText.bodySmall( + project.projectAddress, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], ), ), - const SizedBox(height: 10), - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 20), + ], + ), + + MySpacing.height(12), + _buildDetailRow( + Icons.date_range_outlined, + Colors.teal, + "${DateTimeUtils.convertUtcToLocal(project.startDate.toIso8601String(), format: DateTimeUtils.defaultFormat)} To " + "${DateTimeUtils.convertUtcToLocal(project.endDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}", + fontSize: 13, + ), + + MySpacing.height(12), + + /// Stats + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildStatColumn(Icons.people_alt_rounded, "Team", + "${project.teamSize}", Colors.blue[700]), + _buildStatColumn( + Icons.check_circle, + "Completed", + "${project.completedWork.toStringAsFixed(1)}%", + Colors.green[600]), + _buildStatColumn( + Icons.pending, + "Planned", + "${project.plannedWork.toStringAsFixed(1)}%", + Colors.orange[800]), ], ), ], @@ -211,6 +126,53 @@ class _ServiceProjectScreenState extends State ); } +// Helper to build colored tags + Widget _buildTag(String label) { + return Container( + decoration: BoxDecoration( + color: Colors.indigo.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: + MyText.labelSmall(label, color: Colors.indigo[700], fontWeight: 500), + ); + } + +// Helper for detail row with icon and text + Widget _buildDetailRow(IconData icon, Color iconColor, String value, + {double fontSize = 12}) { + return Row( + children: [ + Icon(icon, size: 19, color: iconColor), + MySpacing.width(8), + Flexible( + child: MyText.bodySmall( + value, + color: Colors.grey[900], + fontWeight: 500, + fontSize: fontSize, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + +// Helper for stats column (icon + label + value) + Widget _buildStatColumn( + IconData icon, String label, String value, Color? color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 19), + SizedBox(height: 3), + MyText.labelSmall(value, color: color, fontWeight: 700), + MyText.bodySmall(label, color: Colors.grey[500], fontSize: 11), + ], + ); + } + Widget _buildEmptyState() { return Center( child: Column( @@ -233,7 +195,7 @@ class _ServiceProjectScreenState extends State return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - /// --- SAME APPBAR AS DETAILS SCREEN --- + /// APPBAR appBar: PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( @@ -249,44 +211,13 @@ class _ServiceProjectScreenState extends State IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), + onPressed: () => Get.back(), ), MySpacing.width(8), - 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 ?? - 'All Projects'; - 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], - ), - ), - ], - ); - }, - ), - ], - ), + MyText.titleLarge( + 'Service Projects', + fontWeight: 700, + color: Colors.black, ), ], ), @@ -296,7 +227,7 @@ class _ServiceProjectScreenState extends State body: Column( children: [ - /// --- SEARCH + FILTER BAR --- + /// SEARCH + FILTER BAR Padding( padding: MySpacing.xy(8, 8), child: Row( @@ -306,7 +237,6 @@ class _ServiceProjectScreenState extends State height: 35, child: TextField( controller: searchController, - onChanged: _filterProjects, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), @@ -315,15 +245,14 @@ class _ServiceProjectScreenState extends State suffixIcon: ValueListenableBuilder( valueListenable: searchController, builder: (context, value, _) { - if (value.text.isEmpty) { + if (value.text.isEmpty) return const SizedBox.shrink(); - } return IconButton( icon: const Icon(Icons.clear, size: 20, color: Colors.grey), onPressed: () { searchController.clear(); - _filterProjects(''); + controller.updateSearch(''); }, ); }, @@ -402,24 +331,31 @@ class _ServiceProjectScreenState extends State ), ), - /// --- PROJECT LIST --- + /// PROJECT LIST Expanded( - child: Obx(() => MyRefreshIndicator( - onRefresh: _refreshProjects, - backgroundColor: Colors.indigo, - color: Colors.white, - child: filteredProjects.isEmpty - ? _buildEmptyState() - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: MySpacing.only( - left: 8, right: 8, top: 4, bottom: 80), - itemCount: filteredProjects.length, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, index) => - _buildProjectCard(filteredProjects[index]), - ), - )), + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + final projects = controller.filteredProjects; + return MyRefreshIndicator( + onRefresh: _refreshProjects, + backgroundColor: Colors.indigo, + color: Colors.white, + child: projects.isEmpty + ? _buildEmptyState() + : ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: MySpacing.only( + left: 8, right: 8, top: 4, bottom: 80), + itemCount: projects.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) => + _buildProjectCard(projects[index]), + ), + ); + }), ), ], ), diff --git a/lib/view/taskPlanning/daily_task_planning.dart b/lib/view/taskPlanning/daily_task_planning.dart index db3c7d2..65bf438 100644 --- a/lib/view/taskPlanning/daily_task_planning.dart +++ b/lib/view/taskPlanning/daily_task_planning.dart @@ -227,8 +227,8 @@ class _DailyTaskPlanningScreenState extends State final buildings = dailyTasks .expand((task) => task.buildings) .where((building) => - (building.plannedWork ?? 0) > 0 || - (building.completedWork ?? 0) > 0) + (building.plannedWork ) > 0 || + (building.completedWork ) > 0) .toList(); if (buildings.isEmpty) { From 6c14fc1507dbc52700971d257663010c671ead98 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 12 Nov 2025 16:48:53 +0530 Subject: [PATCH 07/35] made chnages in details screen and list screen --- .../service_project_screen_controller.dart | 49 +++- .../service_projects_list_model.dart | 231 +++++++++++++++--- .../employees/employee_detail_screen.dart | 53 ++-- .../payment_request_detail_screen.dart | 81 +++--- .../service_project_details_screen.dart | 87 ++++--- .../service_project_screen.dart | 162 ++++++------ 6 files changed, 417 insertions(+), 246 deletions(-) diff --git a/lib/controller/service_project/service_project_screen_controller.dart b/lib/controller/service_project/service_project_screen_controller.dart index ae0b423..401b68b 100644 --- a/lib/controller/service_project/service_project_screen_controller.dart +++ b/lib/controller/service_project/service_project_screen_controller.dart @@ -3,33 +3,56 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/service_project/service_projects_list_model.dart'; class ServiceProjectController extends GetxController { - var projects = [].obs; - var isLoading = false.obs; - var searchQuery = ''.obs; + final projects = [].obs; + final isLoading = false.obs; + final searchQuery = ''.obs; - RxList get filteredProjects { - if (searchQuery.value.isEmpty) return projects; - return projects - .where((p) => - p.name.toLowerCase().contains(searchQuery.value.toLowerCase()) || - p.contactPerson.toLowerCase().contains(searchQuery.value.toLowerCase())) - .toList() - .obs; + /// Computed filtered project list + List get filteredProjects { + final query = searchQuery.value.trim().toLowerCase(); + if (query.isEmpty) return projects; + + return projects.where((p) { + final nameMatch = p.name.toLowerCase().contains(query); + final shortNameMatch = p.shortName.toLowerCase().contains(query); + final addressMatch = p.address.toLowerCase().contains(query); + final contactMatch = p.contactName.toLowerCase().contains(query); + final clientMatch = p.client != null && + (p.client!.name.toLowerCase().contains(query) || + p.client!.contactPerson.toLowerCase().contains(query)); + + return nameMatch || + shortNameMatch || + addressMatch || + contactMatch || + clientMatch; + }).toList(); } + /// Fetch projects from API Future fetchProjects({int pageNumber = 1, int pageSize = 20}) async { try { isLoading.value = true; + final result = await ApiService.getServiceProjectsListApi( - pageNumber: pageNumber, pageSize: pageSize); + pageNumber: pageNumber, + pageSize: pageSize, + ); + if (result != null && result.data != null) { - projects.assignAll(result.data!.data ?? []); + projects.assignAll(result.data!.data); + } else { + projects.clear(); } + } catch (e) { + // Optional: log or show error + rethrow; } finally { isLoading.value = false; } } + /// Update search void updateSearch(String query) { searchQuery.value = query; } diff --git a/lib/model/service_project/service_projects_list_model.dart b/lib/model/service_project/service_projects_list_model.dart index 5d9da34..7117113 100644 --- a/lib/model/service_project/service_projects_list_model.dart +++ b/lib/model/service_project/service_projects_list_model.dart @@ -22,7 +22,7 @@ class ServiceProjectListModel { data: json['data'] != null ? ProjectData.fromJson(json['data']) : null, errors: json['errors'], statusCode: json['statusCode'] ?? 0, - timestamp: DateTime.parse(json['timestamp'] ?? DateTime.now().toIso8601String()), + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), ); } @@ -40,13 +40,13 @@ class ProjectData { final int currentPage; final int totalPages; final int totalEntities; - final List? data; + final List data; ProjectData({ required this.currentPage, required this.totalPages, required this.totalEntities, - this.data, + required this.data, }); factory ProjectData.fromJson(Map json) { @@ -55,7 +55,8 @@ class ProjectData { totalPages: json['totalPages'] ?? 1, totalEntities: json['totalEntites'] ?? 0, data: json['data'] != null - ? List.from(json['data'].map((x) => ProjectItem.fromJson(x))) + ? List.from( + json['data'].map((x) => ProjectItem.fromJson(x))) : [], ); } @@ -64,7 +65,7 @@ class ProjectData { 'currentPage': currentPage, 'totalPages': totalPages, 'totalEntites': totalEntities, - 'data': data?.map((x) => x.toJson()).toList(), + 'data': data.map((x) => x.toJson()).toList(), }; } @@ -72,27 +73,31 @@ class ProjectItem { final String id; final String name; final String shortName; - final String projectAddress; - final String contactPerson; - final DateTime startDate; - final DateTime endDate; - final String projectStatusId; - final int teamSize; - final double completedWork; - final double plannedWork; + final String address; + final DateTime assignedDate; + final Status? status; + final Client? client; + final List services; + final String contactName; + final String contactPhone; + final String contactEmail; + final DateTime createdAt; + final CreatedBy? createdBy; ProjectItem({ required this.id, required this.name, required this.shortName, - required this.projectAddress, - required this.contactPerson, - required this.startDate, - required this.endDate, - required this.projectStatusId, - required this.teamSize, - required this.completedWork, - required this.plannedWork, + required this.address, + required this.assignedDate, + this.status, + this.client, + required this.services, + required this.contactName, + required this.contactPhone, + required this.contactEmail, + required this.createdAt, + this.createdBy, }); factory ProjectItem.fromJson(Map json) { @@ -100,14 +105,24 @@ class ProjectItem { id: json['id'] ?? '', name: json['name'] ?? '', shortName: json['shortName'] ?? '', - projectAddress: json['projectAddress'] ?? '', - contactPerson: json['contactPerson'] ?? '', - startDate: DateTime.parse(json['startDate'] ?? DateTime.now().toIso8601String()), - endDate: DateTime.parse(json['endDate'] ?? DateTime.now().toIso8601String()), - projectStatusId: json['projectStatusId'] ?? '', - teamSize: json['teamSize'] ?? 0, - completedWork: (json['completedWork']?.toDouble() ?? 0.0), - plannedWork: (json['plannedWork']?.toDouble() ?? 0.0), + address: json['address'] ?? '', + assignedDate: + DateTime.tryParse(json['assignedDate'] ?? '') ?? DateTime.now(), + status: + json['status'] != null ? Status.fromJson(json['status']) : null, + client: + json['client'] != null ? Client.fromJson(json['client']) : null, + services: json['services'] != null + ? List.from(json['services'].map((x) => Service.fromJson(x))) + : [], + contactName: json['contactName'] ?? '', + contactPhone: json['contactPhone'] ?? '', + contactEmail: json['contactEmail'] ?? '', + createdAt: + DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + createdBy: json['createdBy'] != null + ? CreatedBy.fromJson(json['createdBy']) + : null, ); } @@ -115,13 +130,155 @@ class ProjectItem { 'id': id, 'name': name, 'shortName': shortName, - 'projectAddress': projectAddress, - 'contactPerson': contactPerson, - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), - 'projectStatusId': projectStatusId, - 'teamSize': teamSize, - 'completedWork': completedWork, - 'plannedWork': plannedWork, + 'address': address, + 'assignedDate': assignedDate.toIso8601String(), + 'status': status?.toJson(), + 'client': client?.toJson(), + 'services': services.map((x) => x.toJson()).toList(), + 'contactName': contactName, + 'contactPhone': contactPhone, + 'contactEmail': contactEmail, + 'createdAt': createdAt.toIso8601String(), + 'createdBy': createdBy?.toJson(), + }; +} + +class Status { + final String id; + final String status; + + Status({ + required this.id, + required this.status, + }); + + factory Status.fromJson(Map json) { + return Status( + id: json['id'] ?? '', + status: json['status'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'status': status, + }; +} + +class Client { + final String id; + final String name; + final String email; + final String contactPerson; + final String address; + final String contactNumber; + final int sprid; + + Client({ + required this.id, + required this.name, + required this.email, + required this.contactPerson, + required this.address, + required this.contactNumber, + required this.sprid, + }); + + factory Client.fromJson(Map json) { + return Client( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', + contactPerson: json['contactPerson'] ?? '', + address: json['address'] ?? '', + contactNumber: json['contactNumber'] ?? '', + sprid: json['sprid'] ?? 0, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'email': email, + 'contactPerson': contactPerson, + 'address': address, + 'contactNumber': contactNumber, + 'sprid': sprid, + }; +} + +class Service { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + Service({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory Service.fromJson(Map json) { + return Service( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'isSystem': isSystem, + 'isActive': isActive, + }; +} + +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'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, }; } diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 904d101..81b32de 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -80,14 +80,9 @@ class _EmployeeDetailPageState extends State with UIMixin { children: [ Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: contentTheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(5), - ), child: Icon( icon, size: 20, - color: contentTheme.primary, ), ), MySpacing.width(16), @@ -95,27 +90,21 @@ class _EmployeeDetailPageState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + MyText( label, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), + color: Colors.grey[600], + fontWeight: 500, ), MySpacing.height(4), - Text( + MyText( value, - style: TextStyle( - fontSize: 15, - color: isActionable && value != 'NA' - ? contentTheme.primary - : Colors.black87, - fontWeight: FontWeight.w500, - decoration: isActionable && value != 'NA' - ? TextDecoration.underline - : TextDecoration.none, - ), + color: isActionable && value != 'NA' + ? Colors.blueAccent + : Colors.black87, + fontWeight: 500, + decoration: isActionable && value != 'NA' + ? TextDecoration.underline + : TextDecoration.none, ), ], ), @@ -151,16 +140,13 @@ class _EmployeeDetailPageState extends State with UIMixin { Icon( titleIcon, size: 20, - color: contentTheme.primary, ), MySpacing.width(8), - Text( + MyText( title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), + fontSize: 16, + fontWeight: 700, + color: Colors.black87, ), ], ), @@ -198,7 +184,7 @@ class _EmployeeDetailPageState extends State with UIMixin { final employee = controller.selectedEmployeeDetails.value; if (employee == null) { - return const Center(child: Text('No employee details found.')); + return Center(child: MyText('No employee details found.')); } return SafeArea( @@ -226,7 +212,7 @@ class _EmployeeDetailPageState extends State with UIMixin { Avatar( firstName: employee.firstName, lastName: employee.lastName, - size: 45, + size: 35, ), MySpacing.width(16), Expanded( @@ -444,9 +430,10 @@ class _EmployeeDetailPageState extends State with UIMixin { ); }, backgroundColor: contentTheme.primary, - label: const Text( + label: MyText( 'Assign to Project', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + fontSize: 14, + fontWeight: 500, ), icon: const Icon(Icons.add), ); diff --git a/lib/view/finance/payment_request_detail_screen.dart b/lib/view/finance/payment_request_detail_screen.dart index 49ffcb6..91a99c2 100644 --- a/lib/view/finance/payment_request_detail_screen.dart +++ b/lib/view/finance/payment_request_detail_screen.dart @@ -362,13 +362,13 @@ class PaymentRequestPermissionHelper { // ------------------ Sub-widgets ------------------ -class _Header extends StatelessWidget with UIMixin { +class _Header extends StatefulWidget { final PaymentRequestData request; final Color Function(String) colorParser; final VoidCallback? onEdit; final EmployeeInfo? employeeInfo; - _Header({ + const _Header({ required this.request, required this.colorParser, this.onEdit, @@ -376,12 +376,17 @@ class _Header extends StatelessWidget with UIMixin { }); @override - Widget build(BuildContext context) { - final statusColor = colorParser(request.expenseStatus.color); + State<_Header> createState() => _HeaderState(); +} - final canEdit = employeeInfo != null && +class _HeaderState extends State<_Header> with UIMixin { + @override + Widget build(BuildContext context) { + final statusColor = widget.colorParser(widget.request.expenseStatus.color); + + final canEdit = widget.employeeInfo != null && PaymentRequestPermissionHelper.canEditPaymentRequest( - employeeInfo, request); + widget.employeeInfo, widget.request); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -390,13 +395,13 @@ class _Header extends StatelessWidget with UIMixin { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MyText.bodyMedium( - 'ID: ${request.paymentRequestUID}', + 'ID: ${widget.request.paymentRequestUID}', fontWeight: 700, fontSize: 14, ), if (canEdit) IconButton( - onPressed: onEdit, + onPressed: widget.onEdit, icon: Icon(Icons.edit, color: contentTheme.primary), tooltip: "Edit Payment Request", ), @@ -417,7 +422,7 @@ class _Header extends StatelessWidget with UIMixin { Expanded( child: MyText.bodySmall( DateTimeUtils.convertUtcToLocal( - request.createdAt.toIso8601String(), + widget.request.createdAt.toIso8601String(), format: 'dd MMM yyyy'), fontWeight: 600, overflow: TextOverflow.ellipsis, @@ -438,7 +443,7 @@ class _Header extends StatelessWidget with UIMixin { SizedBox( width: 100, child: MyText.labelSmall( - request.expenseStatus.displayName, + widget.request.expenseStatus.displayName, color: statusColor, fontWeight: 600, overflow: TextOverflow.ellipsis, @@ -629,66 +634,68 @@ class _DetailsTable extends StatelessWidget { children: [ // Basic Info _labelValueRow("Payment Request ID:", request.paymentRequestUID), - if (request.paidTransactionId != null && request.paidTransactionId!.isNotEmpty) + if (request.paidTransactionId != null && + request.paidTransactionId!.isNotEmpty) _labelValueRow("Transaction ID:", request.paidTransactionId!), _labelValueRow("Payee:", request.payee), _labelValueRow("Project:", request.project.name), _labelValueRow("Expense Category:", request.expenseCategory.name), - + // Amounts - _labelValueRow( - "Amount:", "${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"), + _labelValueRow("Amount:", + "${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"), if (request.baseAmount != null) - _labelValueRow( - "Base Amount:", "${request.currency.symbol} ${request.baseAmount!.toStringAsFixed(2)}"), + _labelValueRow("Base Amount:", + "${request.currency.symbol} ${request.baseAmount!.toStringAsFixed(2)}"), if (request.taxAmount != null) - _labelValueRow( - "Tax Amount:", "${request.currency.symbol} ${request.taxAmount!.toStringAsFixed(2)}"), + _labelValueRow("Tax Amount:", + "${request.currency.symbol} ${request.taxAmount!.toStringAsFixed(2)}"), if (request.expenseCategory.noOfPersonsRequired) _labelValueRow("Additional Persons Required:", "Yes"), if (request.expenseCategory.isAttachmentRequried) _labelValueRow("Attachment Required:", "Yes"), - + // Dates _labelValueRow( "Due Date:", - DateTimeUtils.convertUtcToLocal( - request.dueDate.toIso8601String(), + DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(), format: 'dd MMM yyyy')), _labelValueRow( "Created At:", - DateTimeUtils.convertUtcToLocal( - request.createdAt.toIso8601String(), + DateTimeUtils.convertUtcToLocal(request.createdAt.toIso8601String(), format: 'dd MMM yyyy')), _labelValueRow( "Updated At:", - DateTimeUtils.convertUtcToLocal( - request.updatedAt.toIso8601String(), + DateTimeUtils.convertUtcToLocal(request.updatedAt.toIso8601String(), format: 'dd MMM yyyy')), - + // Payment Info if (request.paidAt != null) _labelValueRow( "Transaction Date:", - DateTimeUtils.convertUtcToLocal( - request.paidAt!.toIso8601String(), + DateTimeUtils.convertUtcToLocal(request.paidAt!.toIso8601String(), format: 'dd MMM yyyy')), if (request.paidBy != null) - _labelValueRow( - "Paid By:", "${request.paidBy!.firstName} ${request.paidBy!.lastName}"), - + _labelValueRow("Paid By:", + "${request.paidBy!.firstName} ${request.paidBy!.lastName}"), + // Flags - _labelValueRow("Advance Payment:", request.isAdvancePayment ? "Yes" : "No"), - _labelValueRow("Expense Created:", request.isExpenseCreated ? "Yes" : "No"), + _labelValueRow( + "Advance Payment:", request.isAdvancePayment ? "Yes" : "No"), + _labelValueRow( + "Expense Created:", request.isExpenseCreated ? "Yes" : "No"), _labelValueRow("Active:", request.isActive ? "Yes" : "No"), - + // Recurring Payment Info if (request.recurringPayment != null) ...[ const SizedBox(height: 6), MyText.bodySmall("Recurring Payment Info:", fontWeight: 600), - _labelValueRow("Recurring ID:", request.recurringPayment!.recurringPaymentUID), - _labelValueRow("Amount:", "${request.currency.symbol} ${request.recurringPayment!.amount.toStringAsFixed(2)}"), - _labelValueRow("Variable Amount:", request.recurringPayment!.isVariable ? "Yes" : "No"), + _labelValueRow( + "Recurring ID:", request.recurringPayment!.recurringPaymentUID), + _labelValueRow("Amount:", + "${request.currency.symbol} ${request.recurringPayment!.amount.toStringAsFixed(2)}"), + _labelValueRow("Variable Amount:", + request.recurringPayment!.isVariable ? "Yes" : "No"), ], // Description & Attachments diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index cea61be..e9f18b9 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -20,15 +20,20 @@ class ServiceProjectDetailsScreen extends StatefulWidget { class _ServiceProjectDetailsScreenState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - final ServiceProjectDetailsController controller = - Get.put(ServiceProjectDetailsController()); + late final TabController _tabController; + late final ServiceProjectDetailsController controller; @override void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); - controller.setProjectId(widget.projectId); + controller = Get.put(ServiceProjectDetailsController()); + + // Fetch project detail safely after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.setProjectId(widget.projectId); + }); } @override @@ -57,38 +62,33 @@ class _ServiceProjectDetailsScreenState children: [ Container( padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.redAccent.withOpacity(0.1), - borderRadius: BorderRadius.circular(5), + child: Icon( + icon, + size: 20, ), - child: Icon(icon, size: 20, color: Colors.redAccent), ), MySpacing.width(16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + MyText.bodySmall( label, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), + fontSize: 12, + color: Colors.grey[600], + fontWeight: 500, ), MySpacing.height(4), - Text( + MyText.bodyMedium( value, - style: TextStyle( - fontSize: 15, - color: isActionable && value != 'NA' - ? Colors.redAccent - : Colors.black87, - fontWeight: FontWeight.w500, - decoration: isActionable && value != 'NA' - ? TextDecoration.underline - : TextDecoration.none, - ), + fontSize: 15, + color: isActionable && value != 'NA' + ? Colors.blueAccent + : Colors.black87, + fontWeight: 500, + decoration: isActionable && value != 'NA' + ? TextDecoration.underline + : TextDecoration.none, ), ], ), @@ -117,14 +117,13 @@ class _ServiceProjectDetailsScreenState children: [ Row( children: [ - Icon(titleIcon, size: 20, color: Colors.redAccent), + Icon(titleIcon, size: 20), MySpacing.width(8), - Text( + MyText.bodyLarge( title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87), + fontSize: 16, + fontWeight: 700, + color: Colors.black87, ), ], ), @@ -148,7 +147,9 @@ class _ServiceProjectDetailsScreenState Widget _buildProfileTab() { final project = controller.projectDetail.value; - if (project == null) return const Center(child: Text("No project data")); + if (project == null) { + return Center(child: MyText.bodyMedium("No project data")); + } return Padding( padding: MySpacing.all(12), @@ -166,8 +167,7 @@ class _ServiceProjectDetailsScreenState padding: const EdgeInsets.all(16), child: Row( children: [ - const Icon(Icons.work_outline, - size: 45, color: Colors.redAccent), + const Icon(Icons.work_outline, size: 35), MySpacing.width(16), Expanded( child: Column( @@ -175,8 +175,8 @@ class _ServiceProjectDetailsScreenState children: [ MyText.titleMedium(project.name, fontWeight: 700), MySpacing.height(6), - MyText.bodySmall( - project.client?.name ?? 'N/A', fontWeight: 500), + MyText.bodySmall(project.client?.name ?? 'N/A', + fontWeight: 500), ], ), ), @@ -211,8 +211,7 @@ class _ServiceProjectDetailsScreenState label: 'Contact Phone', value: project.contactPhone, isActionable: true, - onTap: () => - LauncherUtils.launchPhone(project.contactPhone), + onTap: () => LauncherUtils.launchPhone(project.contactPhone), onLongPress: () => LauncherUtils.copyToClipboard( project.contactPhone, typeLabel: 'Phone'), @@ -222,8 +221,7 @@ class _ServiceProjectDetailsScreenState label: 'Contact Email', value: project.contactEmail, isActionable: true, - onTap: () => - LauncherUtils.launchEmail(project.contactEmail), + onTap: () => LauncherUtils.launchEmail(project.contactEmail), onLongPress: () => LauncherUtils.copyToClipboard( project.contactEmail, typeLabel: 'Email'), @@ -369,9 +367,9 @@ class _ServiceProjectDetailsScreenState indicatorColor: Colors.red, indicatorWeight: 3, isScrollable: false, - tabs: const [ - Tab(text: "Profile"), - Tab(text: "Jobs"), + tabs: [ + Tab(child: MyText.bodyMedium("Profile")), + Tab(child: MyText.bodyMedium("Jobs")), ], ), ), @@ -383,7 +381,8 @@ class _ServiceProjectDetailsScreenState return const Center(child: CircularProgressIndicator()); } if (controller.errorMessage.value.isNotEmpty) { - return Center(child: Text(controller.errorMessage.value)); + return Center( + child: MyText.bodyMedium(controller.errorMessage.value)); } return TabBarView( diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index c4f6b7f..47311c4 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -6,7 +6,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/controller/service_project/service_project_screen_controller.dart'; import 'package:marco/model/service_project/service_projects_list_model.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart '; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/view/service_project/service_project_details_screen.dart'; class ServiceProjectScreen extends StatefulWidget { @@ -21,11 +21,15 @@ class _ServiceProjectScreenState extends State final TextEditingController searchController = TextEditingController(); final ServiceProjectController controller = Get.put(ServiceProjectController()); - @override void initState() { super.initState(); - controller.fetchProjects(); + + // Fetch projects safely after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.fetchProjects(); + }); + searchController.addListener(() { controller.updateSearch(searchController.text); }); @@ -45,17 +49,16 @@ class _ServiceProjectScreenState extends State borderRadius: BorderRadius.circular(14), onTap: () { // Navigate to ServiceProjectDetailsScreen - Get.to( - () => ServiceProjectDetailsScreen(projectId: project.id), - ); + Get.to(() => ServiceProjectDetailsScreen(projectId: project.id)); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// Header Row: Avatar | Name & Tags | Status + /// Project Header Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( @@ -63,62 +66,71 @@ class _ServiceProjectScreenState extends State children: [ MyText.titleMedium( project.name, - fontWeight: 800, - ), - MySpacing.height(2), - Row( - children: [ - if (project.shortName.isNotEmpty) - _buildTag(project.shortName), - if (project.shortName.isNotEmpty) - MySpacing.width(6), - Icon(Icons.location_on, - size: 15, color: Colors.deepOrange.shade400), - MySpacing.width(2), - Flexible( - child: MyText.bodySmall( - project.projectAddress, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ), - ), - ], + fontWeight: 700, ), + MySpacing.height(4), ], ), ), + if (project.status?.status.isNotEmpty ?? false) + Container( + decoration: BoxDecoration( + color: Colors.indigo.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + child: MyText.labelSmall( + project.status!.status, + color: Colors.indigo[700], + fontWeight: 600, + ), + ), ], ), - MySpacing.height(12), + MySpacing.height(10), + + /// Assigned Date _buildDetailRow( Icons.date_range_outlined, Colors.teal, - "${DateTimeUtils.convertUtcToLocal(project.startDate.toIso8601String(), format: DateTimeUtils.defaultFormat)} To " - "${DateTimeUtils.convertUtcToLocal(project.endDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}", + "Assigned: ${DateTimeUtils.convertUtcToLocal(project.assignedDate.toIso8601String(), format: DateTimeUtils.defaultFormat)}", + fontSize: 13, + ), + + MySpacing.height(8), + + /// Client Info + if (project.client != null) + _buildDetailRow( + Icons.account_circle_outlined, + Colors.indigo, + "Client: ${project.client!.name} (${project.client!.contactPerson})", + fontSize: 13, + ), + + MySpacing.height(8), + + /// Contact Info + _buildDetailRow( + Icons.phone, + Colors.green, + "Contact: ${project.contactName} (${project.contactPhone})", fontSize: 13, ), MySpacing.height(12), - /// Stats - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildStatColumn(Icons.people_alt_rounded, "Team", - "${project.teamSize}", Colors.blue[700]), - _buildStatColumn( - Icons.check_circle, - "Completed", - "${project.completedWork.toStringAsFixed(1)}%", - Colors.green[600]), - _buildStatColumn( - Icons.pending, - "Planned", - "${project.plannedWork.toStringAsFixed(1)}%", - Colors.orange[800]), - ], - ), + /// Services List + if (project.services.isNotEmpty) + Wrap( + spacing: 6, + runSpacing: 4, + children: project.services + .map((service) => _buildServiceChip(service.name)) + .toList(), + ), ], ), ), @@ -126,25 +138,27 @@ class _ServiceProjectScreenState extends State ); } -// Helper to build colored tags - Widget _buildTag(String label) { + Widget _buildServiceChip(String name) { return Container( decoration: BoxDecoration( - color: Colors.indigo.withOpacity(0.08), + color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: - MyText.labelSmall(label, color: Colors.indigo[700], fontWeight: 500), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: MyText.labelSmall( + name, + color: Colors.orange[800], + fontWeight: 500, + ), ); } -// Helper for detail row with icon and text Widget _buildDetailRow(IconData icon, Color iconColor, String value, {double fontSize = 12}) { return Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(icon, size: 19, color: iconColor), + Icon(icon, size: 18, color: iconColor), MySpacing.width(8), Flexible( child: MyText.bodySmall( @@ -152,27 +166,12 @@ class _ServiceProjectScreenState extends State color: Colors.grey[900], fontWeight: 500, fontSize: fontSize, - overflow: TextOverflow.ellipsis, ), ), ], ); } -// Helper for stats column (icon + label + value) - Widget _buildStatColumn( - IconData icon, String label, String value, Color? color) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 19), - SizedBox(height: 3), - MyText.labelSmall(value, color: color, fontWeight: 700), - MyText.bodySmall(label, color: Colors.grey[500], fontSize: 11), - ], - ); - } - Widget _buildEmptyState() { return Center( child: Column( @@ -194,8 +193,6 @@ class _ServiceProjectScreenState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - - /// APPBAR appBar: PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( @@ -211,7 +208,7 @@ class _ServiceProjectScreenState extends State IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.back(), + onPressed: () => Get.toNamed('/dashboard'), ), MySpacing.width(8), MyText.titleLarge( @@ -224,10 +221,9 @@ class _ServiceProjectScreenState extends State ), ), ), - body: Column( children: [ - /// SEARCH + FILTER BAR + /// Search bar and actions Padding( padding: MySpacing.xy(8, 8), child: Row( @@ -245,8 +241,9 @@ class _ServiceProjectScreenState extends State suffixIcon: ValueListenableBuilder( valueListenable: searchController, builder: (context, value, _) { - if (value.text.isEmpty) + if (value.text.isEmpty) { return const SizedBox.shrink(); + } return IconButton( icon: const Icon(Icons.clear, size: 20, color: Colors.grey), @@ -308,10 +305,11 @@ class _ServiceProjectScreenState extends State const PopupMenuItem( enabled: false, height: 30, - child: Text("Actions", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey)), + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.grey), + ), ), const PopupMenuItem( value: 1, @@ -331,7 +329,7 @@ class _ServiceProjectScreenState extends State ), ), - /// PROJECT LIST + /// Project List Expanded( child: Obx(() { if (controller.isLoading.value) { From cdba511d434fcf24cc4b0405ca8043d81af8c46d Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 17:17:01 +0530 Subject: [PATCH 08/35] implementation of manage reporting inside employee profile --- .../employee/employees_screen_controller.dart | 50 +++++++ .../employees/employee_detail_screen.dart | 71 +++++++++- .../manage_reporting_bottom_sheet.dart | 134 ++++++++++++++---- 3 files changed, 224 insertions(+), 31 deletions(-) diff --git a/lib/controller/employee/employees_screen_controller.dart b/lib/controller/employee/employees_screen_controller.dart index 7d95421..a8d9f1a 100644 --- a/lib/controller/employee/employees_screen_controller.dart +++ b/lib/controller/employee/employees_screen_controller.dart @@ -21,6 +21,10 @@ class EmployeesScreenController extends GetxController { /// ✅ Upload state tracking (if needed later) RxMap uploadingStates = {}.obs; + RxList selectedEmployeePrimaryManagers = [].obs; + RxList selectedEmployeeSecondaryManagers = + [].obs; + @override void onInit() { super.onInit(); @@ -86,6 +90,52 @@ class EmployeesScreenController extends GetxController { isLoadingEmployeeDetails.value = false; } + /// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId + Future fetchReportingManagers(String? employeeId) async { + if (employeeId == null || employeeId.isEmpty) return; + + try { + // ✅ Always clear before new fetch (to avoid mixing old data) + selectedEmployeePrimaryManagers.clear(); + selectedEmployeeSecondaryManagers.clear(); + + // Fetch from existing API helper + final data = await ApiService.getOrganizationHierarchyList(employeeId); + + if (data == null || data.isEmpty) { + update(['employee_screen_controller']); + return; + } + + for (final item in data) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + + final emp = EmployeeModel.fromJson(reportTo); + final isPrimary = item['isPrimary'] == true; + + if (isPrimary) { + if (!selectedEmployeePrimaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeePrimaryManagers.add(emp); + } + } else { + if (!selectedEmployeeSecondaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeeSecondaryManagers.add(emp); + } + } + } catch (_) { + // ignore malformed items + } + } + + update(['employee_screen_controller']); + } catch (e) { + logSafe("Error fetching reporting managers for $employeeId", + level: LogLevel.error, error: e); + } + } + /// 🔹 Clear all employee data void clearEmployees() { employees.clear(); diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 217de6c..0cb20c2 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -38,6 +38,7 @@ class _EmployeeDetailPageState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { controller.fetchEmployeeDetails(widget.employeeId); + controller.fetchReportingManagers(widget.employeeId); }); } @@ -207,6 +208,7 @@ class _EmployeeDetailPageState extends State with UIMixin { child: MyRefreshIndicator( onRefresh: () async { await controller.fetchEmployeeDetails(widget.employeeId); + await controller.fetchReportingManagers(employee.id); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -312,10 +314,14 @@ class _EmployeeDetailPageState extends State with UIMixin { action: 0, ), hideMainSelector: true, - hideLoggedUserFromSelection: - true, + hideLoggedUserFromSelection: true, + loggedUserId: + controller.selectedEmployeeDetails.value?.id, ), ); + + // 🔄 Refresh reporting managers after editing + await controller.fetchReportingManagers(employee.id); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -340,6 +346,58 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ), ), + Obx(() { + final primary = + controller.selectedEmployeePrimaryManagers; + final secondary = + controller.selectedEmployeeSecondaryManagers; + + if (primary.isEmpty && secondary.isEmpty) { + return const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'No reporting managers assigned', + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only( + top: 8.0, left: 8, right: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Primary → ${_getManagerNames(primary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Secondary → ${_getManagerNames(secondary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }) ], ), @@ -513,4 +571,13 @@ class _EmployeeDetailPageState extends State with UIMixin { }), ); } + + String _getManagerNames(List managers) { + if (managers.isEmpty) return '—'; + return managers + .map((m) => + '${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim()) + .where((name) => name.isNotEmpty) + .join(', '); + } } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index 3eb5dd4..ba035c6 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -13,14 +13,16 @@ class ManageReportingBottomSheet extends StatefulWidget { final EmployeeModel? initialEmployee; final bool hideMainSelector; final bool renderAsCard; - final bool hideLoggedUserFromSelection; // ✅ new + final bool hideLoggedUserFromSelection; + final String? loggedUserId; const ManageReportingBottomSheet({ super.key, this.initialEmployee, this.hideMainSelector = false, this.renderAsCard = false, - this.hideLoggedUserFromSelection = false, // default false + this.hideLoggedUserFromSelection = false, + this.loggedUserId, }); @override @@ -211,21 +213,76 @@ class _ManageReportingBottomSheetState Future _handleSubmit() async { if (_selectedEmployee == null) { showAppSnackbar( - title: 'Error', - message: 'Please select the employee.', - type: SnackbarType.error); + 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); + title: 'Error', + message: 'Please select at least one primary employee.', + type: SnackbarType.error, + ); return; } + final employeeId = _selectedEmployee!.id; + + // === BUILD PAYLOAD (updated logic) === + + // fetch current assignments so we can deactivate old ones + List? currentAssignments; + try { + currentAssignments = + await ApiService.getOrganizationHierarchyList(employeeId); + } catch (_) { + currentAssignments = null; + } + + // helper sets of newly selected ids + final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet(); + final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet(); + final List> payload = []; + // 1) For current active assignments: if they are not in new selections -> add isActive:false + if (currentAssignments != null && currentAssignments.isNotEmpty) { + for (final item in currentAssignments) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + final reportToId = reportTo['id'] as String?; + if (reportToId == null) continue; + final isPrimary = item['isPrimary'] == true; + final currentlyActive = + item['isActive'] == true || item['isActive'] == null; // be safe + + // if currently active and not included in new selection -> mark false + if (currentlyActive) { + if (isPrimary && !newPrimaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": true, + "isActive": false, + }); + } else if (!isPrimary && !newSecondaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": false, + "isActive": false, + }); + } + } + } catch (_) { + // ignore malformed items (same behavior as before) + } + } + } + + // 2) Add new primary (active) for (final emp in _selectedPrimary) { payload.add({ "reportToId": emp.id, @@ -233,6 +290,8 @@ class _ManageReportingBottomSheetState "isActive": true, }); } + + // 3) Add new secondary (active) for (final emp in _selectedSecondary) { payload.add({ "reportToId": emp.id, @@ -242,11 +301,13 @@ class _ManageReportingBottomSheetState } setState(() => _isSubmitting = true); - // show loader - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); - final employeeId = _selectedEmployee!.id; + // show loader + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + final success = await ApiService.manageOrganizationHierarchy( employeeId: employeeId, payload: payload, @@ -254,25 +315,36 @@ class _ManageReportingBottomSheetState // hide loader if (Get.isDialogOpen == true) Get.back(); - setState(() => _isSubmitting = false); if (success) { showAppSnackbar( - title: 'Success', - message: 'Reporting assigned successfully', - type: SnackbarType.success); + title: 'Success', + message: 'Reporting assigned successfully', + type: SnackbarType.success, + ); - // Optionally refresh the saved hierarchy (not necessary here) but we can call: + // ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers + try { + final empId = employeeId; + final EmployeesScreenController controller = Get.find(); + await controller.fetchReportingManagers(empId); + await controller.fetchEmployeeDetails(empId); + } catch (_) { + // ignore if controller not found — not critical + } + + // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset reporter selections for next assignment + // Keep sheet open and reset selections _resetForm(); } else { showAppSnackbar( - title: 'Error', - message: 'Failed to assign reporting. Please try again.', - type: SnackbarType.error); + title: 'Error', + message: 'Failed to assign reporting. Please try again.', + type: SnackbarType.error, + ); } } @@ -354,11 +426,6 @@ class _ManageReportingBottomSheetState backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => setState(() { - _selectedEmployee = null; - _selectEmployeeController.clear(); - _resetReportersOnly(); - }), ), ) else @@ -460,12 +527,21 @@ class _ManageReportingBottomSheetState spacing: 6, runSpacing: 6, children: selectedList.map((emp) { + final isProfileEmployee = widget.initialEmployee != null && + emp.id == widget.initialEmployee!.id; + return Chip( label: Text(emp.name, style: const TextStyle(fontSize: 12)), - backgroundColor: Colors.indigo.shade50, + backgroundColor: isProfileEmployee + ? Colors.indigo.shade50 + : Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), - deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => selectedList.remove(emp), + // Only show delete icon / action for non-profile employees + deleteIcon: isProfileEmployee + ? null + : const Icon(Icons.close, size: 16), + onDeleted: + isProfileEmployee ? null : () => selectedList.remove(emp), ); }).toList(), ); From 68f17214fd07b69ad41fefb9df070cb75f0bb03a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 12 Nov 2025 17:58:33 +0530 Subject: [PATCH 09/35] made chnages into dynamic menus --- lib/helpers/utils/permission_constants.dart | 49 +- lib/helpers/widgets/my_custom_skeleton.dart | 43 ++ lib/model/dynamicMenu/dynamic_menu_model.dart | 4 + lib/view/dashboard/dashboard_screen.dart | 457 +++++++----------- lib/view/finance/finance_screen.dart | 208 ++++---- 5 files changed, 398 insertions(+), 363 deletions(-) diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index f257cfc..733f6ab 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -25,14 +25,16 @@ class Permissions { // ------------------- Project Infrastructure -------------------------- /// Permission to manage project infrastructure (e.g., site details) - static const String manageProjectInfra = "cf2825ad-453b-46aa-91d9-27c124d63373"; + static const String manageProjectInfra = + "cf2825ad-453b-46aa-91d9-27c124d63373"; /// Permission to view infrastructure-related details static const String viewProjectInfra = "8d7cc6e3-9147-41f7-aaa7-fa507e450bd4"; // ------------------- Attendance Management --------------------------- /// Permission to regularize (edit/update) attendance records - static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; + static const String regularizeAttendance = + "57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; // ------------------- Task Management --------------------------------- /// Permission to create and manage tasks @@ -90,7 +92,8 @@ class Permissions { // ------------------- Application Roles ------------------------------- /// Application role ID for users with full expense management rights - static const String expenseManagement = "a4e25142-449b-4334-a6e5-22f70e4732d7"; + static const String expenseManagement = + "a4e25142-449b-4334-a6e5-22f70e4732d7"; // ------------------- Document Entities ------------------------------- /// Entity ID for project documents @@ -118,3 +121,43 @@ class Permissions { /// Permission to verify documents static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0"; } + +/// Contains constants for menu item IDs fetched from the sidebar menu API. +class MenuItems { + /// Dashboard menu + static const String dashboard = "29e03eda-03e8-4714-92fa-67ae0dc53202"; + + /// Daily Task Planning menu + static const String dailyTaskPlanning = + "77ac5205-f823-442e-b9e4-2420d658aa02"; + + /// Daily Progress Report menu + static const String dailyProgressReport = + "299e3cf5-d034-4403-b4a1-ea46d2714832"; + + /// Employees menu + static const String employees = "78f0206d-c6cc-44d0-832a-2031ed203018"; + + /// Attendance menu + static const String attendance = "2f212030-f36b-456c-8e7c-11f00f9ba42b"; + + /// Directory menu + static const String directory = "31bc367b-7c58-4604-95eb-da059a384103"; + + /// Expense & Reimbursement menu + static const String expenseReimbursement = + "0f0dc1a7-1aca-4cdb-9d7a-8a769ce40728"; + + /// Payment Requests menu + static const String paymentRequests = "b350a59f-2372-4f68-8dcf-f7cfc44523ca"; + + /// Advance Payment Statements menu + static const String advancePaymentStatements = + "e0251cc1-e6d9-417a-9c76-489cc4b6c347"; + + /// Finance menu + static const String finance = "5ac409dd-bbe0-4d56-bcb9-229bd3a6353c"; + + /// Documents menu + static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3"; +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index b81ee63..5bb5309 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -33,6 +33,48 @@ class SkeletonLoaders { ); } +// Inside SkeletonLoaders class + static Widget dashboardCardsSkeleton({double? maxWidth}) { + return LayoutBuilder(builder: (context, constraints) { + double width = maxWidth ?? constraints.maxWidth; + int crossAxisCount = (width ~/ 80).clamp(2, 4); + double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount; + + return Wrap( + spacing: 6, + runSpacing: 6, + children: List.generate(6, (index) { + return MyCard.bordered( + width: cardWidth, + height: 60, + paddingAll: 4, + borderRadiusAll: 5, + border: Border.all(color: Colors.grey.withOpacity(0.15)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + MySpacing.height(4), + Container( + width: cardWidth * 0.5, + height: 10, + color: Colors.grey.shade300, + ), + ], + ), + ); + }), + ); + }); + } + // Inside SkeletonLoaders class static Widget paymentRequestListSkeletonLoader() { return ListView.separated( @@ -256,6 +298,7 @@ class SkeletonLoaders { ), ); } + // Employee Detail Skeleton Loader static Widget employeeDetailSkeletonLoader() { return SingleChildScrollView( diff --git a/lib/model/dynamicMenu/dynamic_menu_model.dart b/lib/model/dynamicMenu/dynamic_menu_model.dart index 8348447..b70b699 100644 --- a/lib/model/dynamicMenu/dynamic_menu_model.dart +++ b/lib/model/dynamicMenu/dynamic_menu_model.dart @@ -59,11 +59,13 @@ class MenuItem { final String id; // Unique item ID final String name; // Display text final bool available; // Availability flag + final String mobileLink; // Mobile navigation link MenuItem({ required this.id, required this.name, required this.available, + required this.mobileLink, }); /// Creates MenuItem from JSON map @@ -72,6 +74,7 @@ class MenuItem { id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', available: json['available'] as bool? ?? false, + mobileLink: json['mobileLink'] as String? ?? '', ); } @@ -81,6 +84,7 @@ class MenuItem { 'id': id, 'name': name, 'available': available, + 'mobileLink': mobileLink, }; } } diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index b8c1402..6ff69e3 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -3,37 +3,24 @@ import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_card.dart'; -import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart'; -import 'package:marco/view/layouts/layout.dart'; -import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart'; -import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart'; +import 'package:marco/view/layouts/layout.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); - static const String dashboardRoute = "/dashboard"; - static const String employeesRoute = "/dashboard/employees"; - static const String projectsRoute = "/dashboard"; - static const String attendanceRoute = "/dashboard/attendance"; - static const String tasksRoute = "/dashboard/daily-task"; - static const String dailyTasksRoute = "/dashboard/daily-task-Planning"; - static const String dailyTasksProgressRoute = - "/dashboard/daily-task-progress"; - static const String directoryMainPageRoute = "/dashboard/directory-main-page"; - static const String financeMainPageRoute = "/dashboard/finance"; - static const String documentMainPageRoute = "/dashboard/document-main-page"; - static const String serviceprojectsRoute = "/dashboard/service-projects"; - @override State createState() => _DashboardScreenState(); } @@ -42,6 +29,7 @@ class _DashboardScreenState extends State with UIMixin { final DashboardController dashboardController = Get.put(DashboardController(), permanent: true); final DynamicMenuController menuController = Get.put(DynamicMenuController()); + final ProjectController projectController = Get.find(); bool hasMpin = true; @@ -60,11 +48,11 @@ class _DashboardScreenState extends State with UIMixin { Widget build(BuildContext context) { return Layout( child: SingleChildScrollView( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildDashboardStats(context), + _buildDashboardCards(), MySpacing.height(24), _buildAttendanceChartSection(), MySpacing.height(24), @@ -80,13 +68,9 @@ class _DashboardScreenState extends State with UIMixin { child: DashboardOverviewWidgets.tasksOverview(), ), MySpacing.height(24), - ExpenseByStatusWidget(controller: dashboardController), MySpacing.height(24), - - // Expense Type Report Chart ExpenseTypeReportChart(), - MySpacing.height(24), MonthlyExpenseDashboardChart(), ], @@ -95,7 +79,162 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Project Progress Chart Section + /// ---------------- Dynamic Dashboard Cards ---------------- + Widget _buildDashboardCards() { + return Obx(() { + if (menuController.isLoading.value) { + return SkeletonLoaders.dashboardCardsSkeleton(); + } + + if (menuController.hasError.value || menuController.menuItems.isEmpty) { + return const Center( + child: Text( + "Failed to load menus. Please try again later.", + style: TextStyle(color: Colors.red), + ), + ); + } + + final projectSelected = projectController.selectedProject != null; + + // Define dashboard card meta with order + final List cardOrder = [ + MenuItems.attendance, + MenuItems.employees, + MenuItems.dailyTaskPlanning, + MenuItems.dailyProgressReport, + MenuItems.directory, + MenuItems.finance, + MenuItems.documents, + ]; + + final Map cardMeta = { + MenuItems.attendance: + _DashboardCardMeta(LucideIcons.scan_face, contentTheme.success), + MenuItems.employees: + _DashboardCardMeta(LucideIcons.users, contentTheme.warning), + MenuItems.dailyTaskPlanning: + _DashboardCardMeta(LucideIcons.logs, contentTheme.info), + MenuItems.dailyProgressReport: + _DashboardCardMeta(LucideIcons.list_todo, contentTheme.info), + MenuItems.directory: + _DashboardCardMeta(LucideIcons.folder, contentTheme.info), + MenuItems.finance: + _DashboardCardMeta(LucideIcons.wallet, contentTheme.info), + MenuItems.documents: + _DashboardCardMeta(LucideIcons.file_text, contentTheme.info), + }; + + // Filter only available menus that exist in cardMeta + final allowedMenusMap = { + for (var menu in menuController.menuItems) + if (menu.available && cardMeta.containsKey(menu.id)) menu.id: menu + }; + + if (allowedMenusMap.isEmpty) { + return const Center( + child: Text( + "No accessible modules found.", + style: TextStyle(color: Colors.grey), + ), + ); + } + + // Create list of cards in fixed order + final stats = + cardOrder.where((id) => allowedMenusMap.containsKey(id)).map((id) { + final menu = allowedMenusMap[id]!; + final meta = cardMeta[id]!; + return _DashboardStatItem( + meta.icon, menu.name, meta.color, menu.mobileLink); + }).toList(); + + return LayoutBuilder(builder: (context, constraints) { + int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); + double cardWidth = + (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; + + return Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.start, + children: stats + .map((stat) => + _buildDashboardCard(stat, projectSelected, cardWidth)) + .toList(), + ); + }); + }); + } + + Widget _buildDashboardCard( + _DashboardStatItem stat, bool isProjectSelected, double width) { + final isEnabled = stat.title == "Attendance" ? true : isProjectSelected; + + return Opacity( + opacity: isEnabled ? 1.0 : 0.4, + child: IgnorePointer( + ignoring: !isEnabled, + child: InkWell( + onTap: () => _onDashboardCardTap(stat, isEnabled), + borderRadius: BorderRadius.circular(5), + child: MyCard.bordered( + width: width, + height: 60, + paddingAll: 4, + borderRadiusAll: 5, + border: Border.all(color: Colors.grey.withOpacity(0.15)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: stat.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + stat.icon, + size: 16, + color: stat.color, + ), + ), + MySpacing.height(4), + Flexible( + child: Text( + stat.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + ), + maxLines: 2, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _onDashboardCardTap(_DashboardStatItem statItem, bool isEnabled) { + if (!isEnabled) { + Get.defaultDialog( + title: "No Project Selected", + middleText: "Please select a project before accessing this module.", + confirm: ElevatedButton( + onPressed: () => Get.back(), + child: const Text("OK"), + ), + ); + } else { + Get.toNamed(statItem.route); + } + } + + /// ---------------- Project Progress Chart ---------------- Widget _buildProjectProgressChartSection() { return Obx(() { if (dashboardController.projectChartData.isEmpty) { @@ -119,267 +258,45 @@ class _DashboardScreenState extends State with UIMixin { }); } - /// Attendance Chart Section + /// ---------------- Attendance Chart ---------------- Widget _buildAttendanceChartSection() { return Obx(() { - final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); - - if (!isAttendanceAllowed) { - // 🚫 Don't render anything if attendance menu is not allowed + final attendanceMenu = menuController.menuItems + .firstWhereOrNull((m) => m.id == MenuItems.attendance); + if (attendanceMenu == null || !attendanceMenu.available) return const SizedBox.shrink(); - } - return GetBuilder( - id: 'dashboard_controller', - builder: (projectController) { - final isProjectSelected = projectController.selectedProject != null; - return Opacity( - opacity: isProjectSelected ? 1.0 : 0.4, - child: IgnorePointer( - ignoring: !isProjectSelected, - child: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: SizedBox( - height: 400, - child: AttendanceDashboardChart(), - ), - ), - ), - ); - }, - ); - }); - } - - /// No Project Assigned Message - Widget _buildNoProjectMessage() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: MyCard( - color: Colors.orange.withOpacity(0.1), - paddingAll: 12, - child: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.orange), - MySpacing.width(8), - Expanded( - child: MyText.bodySmall( - "No projects assigned yet. Please contact your manager to get started.", - color: Colors.orange.shade800, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } - - /// Loading Skeletons - Widget _buildLoadingSkeleton(BuildContext context) { - return Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate( - 4, - (index) => - _buildStatCardSkeleton(MediaQuery.of(context).size.width / 3), - ), - ); - } - - /// Skeleton Card - Widget _buildStatCardSkeleton(double width) { - return MyCard.bordered( - width: width, - height: 100, - paddingAll: 5, - borderRadiusAll: 5, - border: Border.all(color: Colors.grey.withOpacity(0.15)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyContainer.rounded( - paddingAll: 12, - color: Colors.grey.shade300, - child: const SizedBox(width: 18, height: 18), - ), - MySpacing.height(8), - Container( - height: 12, - width: 60, - color: Colors.grey.shade300, - ), - ], - ), - ); - } - - /// Dashboard Statistics Section - Widget _buildDashboardStats(BuildContext context) { - return Obx(() { - if (menuController.isLoading.value) { - return _buildLoadingSkeleton(context); - } - - if (menuController.hasError.value || menuController.menuItems.isEmpty) { - return Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: MyText.bodySmall( - "Failed to load menus. Please try again later.", - color: Colors.red, - ), - ), - ); - } - - final projectController = Get.find(); final isProjectSelected = projectController.selectedProject != null; - // Keep previous stat items (icons, title, routes) - final stats = [ - _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, - DashboardScreen.attendanceRoute), - _StatItem(LucideIcons.users, "Employees", contentTheme.warning, - DashboardScreen.employeesRoute), - _StatItem(LucideIcons.logs, "Daily Task Planning", contentTheme.info, - DashboardScreen.dailyTasksRoute), - _StatItem(LucideIcons.list_todo, "Daily Progress Report", - contentTheme.info, DashboardScreen.dailyTasksProgressRoute), - _StatItem(LucideIcons.folder, "Directory", contentTheme.info, - DashboardScreen.directoryMainPageRoute), - _StatItem(LucideIcons.wallet, "Finance", contentTheme.info, - DashboardScreen.financeMainPageRoute), - _StatItem(LucideIcons.file_text, "Documents", contentTheme.info, - DashboardScreen.documentMainPageRoute), - _StatItem(LucideIcons.briefcase, "Service Projects", contentTheme.info, - DashboardScreen.serviceprojectsRoute), - ]; - - // Safe menu check function to avoid exceptions - bool _isMenuAllowed(String menuTitle) { - try { - return menuController.menuItems.isNotEmpty - ? menuController.isMenuAllowed(menuTitle) - : false; - } catch (e) { - return false; - } - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isProjectSelected) _buildNoProjectMessage(), - LayoutBuilder( - builder: (context, constraints) { - int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 8); - double cardWidth = - (constraints.maxWidth - (crossAxisCount - 1) * 6) / - crossAxisCount; - - return Wrap( - spacing: 6, - runSpacing: 6, - alignment: WrapAlignment.start, - children: stats - .where((stat) => - stat.title == "Service Projects" || - _isMenuAllowed(stat.title)) - .map((stat) => - _buildStatCard(stat, isProjectSelected, cardWidth)) - .toList(), - ); - }, - ), - ], - ); - }); - } - - /// Stat Card (Compact + Small) - Widget _buildStatCard( - _StatItem statItem, bool isProjectSelected, double width) { - const double cardHeight = 60; - - final bool isEnabled = statItem.title == "Attendance" || isProjectSelected; - - return Opacity( - opacity: isEnabled ? 1.0 : 0.4, - child: IgnorePointer( - ignoring: !isEnabled, - child: InkWell( - onTap: () => _handleStatCardTap(statItem, isEnabled), - borderRadius: BorderRadius.circular(5), - child: MyCard.bordered( - width: width, - height: cardHeight, - paddingAll: 4, - borderRadiusAll: 5, - border: Border.all(color: Colors.grey.withOpacity(0.15)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildStatCardIconCompact(statItem, size: 12), - MySpacing.height(4), - Flexible( - child: Text( - statItem.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 8, - overflow: TextOverflow.visible, - ), - maxLines: 2, - softWrap: true, - ), - ), - ], + return Opacity( + opacity: isProjectSelected ? 1.0 : 0.4, + child: IgnorePointer( + ignoring: !isProjectSelected, + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: SizedBox( + height: 400, + child: AttendanceDashboardChart(), ), ), ), - ), - ); - } - - /// Compact Icon (smaller) - Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) { - return MyContainer.rounded( - paddingAll: 4, - color: statItem.color.withOpacity(0.1), - child: Icon( - statItem.icon, - size: size, - color: statItem.color, - ), - ); - } - - /// Handle Tap - void _handleStatCardTap(_StatItem statItem, bool isEnabled) { - if (!isEnabled) { - Get.defaultDialog( - title: "No Project Selected", - middleText: - "You need to select a project before accessing this section.", - confirm: ElevatedButton( - onPressed: () => Get.back(), - child: const Text("OK"), - ), ); - } else { - Get.toNamed(statItem.route); - } + }); } } -class _StatItem { +/// ---------------- Dashboard Card Models ---------------- +class _DashboardStatItem { final IconData icon; final String title; final Color color; final String route; - _StatItem(this.icon, this.title, this.color, this.route); + _DashboardStatItem(this.icon, this.title, this.color, this.route); +} + +class _DashboardCardMeta { + final IconData icon; + final Color color; + _DashboardCardMeta(this.icon, this.color); } diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart index c60bc64..eaa1508 100644 --- a/lib/view/finance/finance_screen.dart +++ b/lib/view/finance/finance_screen.dart @@ -11,6 +11,8 @@ import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/model/dynamicMenu/dynamic_menu_model.dart'; class FinanceScreen extends StatefulWidget { const FinanceScreen({super.key}); @@ -27,6 +29,7 @@ class _FinanceScreenState extends State late Animation _fadeAnimation; final DashboardController dashboardController = Get.put(DashboardController(), permanent: true); + @override void initState() { super.initState(); @@ -117,8 +120,7 @@ class _FinanceScreenState extends State return const Center(child: CircularProgressIndicator()); } - if (menuController.hasError.value || - menuController.menuItems.isEmpty) { + if (menuController.hasError.value || menuController.menuItems.isEmpty) { return const Center( child: Text( "Failed to load menus. Please try again later.", @@ -127,10 +129,18 @@ class _FinanceScreenState extends State ); } - // ✅ Only allow finance cards if "Expense" menu is allowed - final isExpenseAllowed = menuController.isMenuAllowed("Expense & Reimbursement"); + // Filter allowed Finance menus dynamically + final financeMenuIds = [ + MenuItems.expenseReimbursement, + MenuItems.paymentRequests, + MenuItems.advancePaymentStatements, + ]; - if (!isExpenseAllowed) { + final financeMenus = menuController.menuItems + .where((m) => financeMenuIds.contains(m.id) && m.available) + .toList(); + + if (financeMenus.isEmpty) { return const Center( child: Text( "You don’t have access to the Finance section.", @@ -143,7 +153,7 @@ class _FinanceScreenState extends State padding: const EdgeInsets.all(16), child: Column( children: [ - _buildFinanceModulesCompact(), + _buildFinanceModulesCompact(financeMenus), MySpacing.height(24), ExpenseByStatusWidget(controller: dashboardController), MySpacing.height(24), @@ -159,103 +169,115 @@ class _FinanceScreenState extends State } // --- Finance Modules (Compact Dashboard-style) --- - Widget _buildFinanceModulesCompact() { - final stats = [ - _FinanceStatItem(LucideIcons.badge_dollar_sign, "Expense & Reimbursement", - contentTheme.info, "/dashboard/expense-main-page"), - _FinanceStatItem(LucideIcons.receipt_text, "Payment Request", - contentTheme.primary, "/dashboard/payment-request"), - _FinanceStatItem(LucideIcons.wallet, "Advance Payment", - contentTheme.warning, "/dashboard/advance-payment"), - ]; +Widget _buildFinanceModulesCompact(List financeMenus) { + // Map menu IDs to icon + color + final Map financeCardMeta = { + MenuItems.expenseReimbursement: _FinanceCardMeta(LucideIcons.badge_dollar_sign, contentTheme.info), + MenuItems.paymentRequests: _FinanceCardMeta(LucideIcons.receipt_text, contentTheme.primary), + MenuItems.advancePaymentStatements: _FinanceCardMeta(LucideIcons.wallet, contentTheme.warning), + }; - final projectSelected = projectController.selectedProject != null; + // Build the stat items using API-provided mobileLink + final stats = financeMenus.map((menu) { + final meta = financeCardMeta[menu.id]!; - return LayoutBuilder(builder: (context, constraints) { - int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); - double cardWidth = - (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; + // --- Log the routing info --- + debugPrint( + "[Finance Card] ID: ${menu.id}, Title: ${menu.name}, Route: ${menu.mobileLink}"); - return Wrap( - spacing: 6, - runSpacing: 6, - alignment: WrapAlignment.end, - children: stats - .map((stat) => - _buildFinanceModuleCard(stat, projectSelected, cardWidth)) - .toList(), - ); - }); - } + return _FinanceStatItem( + meta.icon, + menu.name, + meta.color, + menu.mobileLink, // Each card navigates to its own route + ); + }).toList(); - Widget _buildFinanceModuleCard( - _FinanceStatItem stat, bool isProjectSelected, double width) { - final bool isEnabled = isProjectSelected; + final projectSelected = projectController.selectedProject != null; - return Opacity( - opacity: isEnabled ? 1.0 : 0.4, - child: IgnorePointer( - ignoring: !isEnabled, - child: InkWell( - onTap: () => _onCardTap(stat, isEnabled), - borderRadius: BorderRadius.circular(5), - child: MyCard.bordered( - width: width, - height: 60, - paddingAll: 4, - borderRadiusAll: 5, - border: Border.all(color: Colors.grey.withOpacity(0.15)), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: stat.color.withOpacity(0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Icon( - stat.icon, - size: 16, - color: stat.color, - ), + return LayoutBuilder(builder: (context, constraints) { + // Determine number of columns dynamically + int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); + double cardWidth = (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; + + return Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.end, + children: stats + .map((stat) => _buildFinanceModuleCard(stat, projectSelected, cardWidth)) + .toList(), + ); + }); +} + +Widget _buildFinanceModuleCard( + _FinanceStatItem stat, bool isProjectSelected, double width) { + return Opacity( + opacity: isProjectSelected ? 1.0 : 0.4, // Dim if no project selected + child: IgnorePointer( + ignoring: !isProjectSelected, + child: InkWell( + onTap: () => _onCardTap(stat, isProjectSelected), + borderRadius: BorderRadius.circular(5), + child: MyCard.bordered( + width: width, + height: 60, + paddingAll: 4, + borderRadiusAll: 5, + border: Border.all(color: Colors.grey.withOpacity(0.15)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: stat.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), ), - MySpacing.height(4), - Flexible( - child: Text( - stat.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 10, - overflow: TextOverflow.ellipsis, - ), - maxLines: 2, - softWrap: true, - ), + child: Icon( + stat.icon, + size: 16, + color: stat.color, ), - ], - ), + ), + MySpacing.height(4), + Flexible( + child: Text( + stat.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, + ), + maxLines: 2, + softWrap: true, + ), + ), + ], ), ), ), - ); - } + ), + ); +} - void _onCardTap(_FinanceStatItem statItem, bool isEnabled) { - if (!isEnabled) { - Get.defaultDialog( - title: "No Project Selected", - middleText: "Please select a project before accessing this section.", - confirm: ElevatedButton( - onPressed: () => Get.back(), - child: const Text("OK"), - ), - ); - } else { - Get.toNamed(statItem.route); - } +void _onCardTap(_FinanceStatItem statItem, bool isEnabled) { + if (!isEnabled) { + Get.defaultDialog( + title: "No Project Selected", + middleText: "Please select a project before accessing this section.", + confirm: ElevatedButton( + onPressed: () => Get.back(), + child: const Text("OK"), + ), + ); + } else { + // Navigate to the card's specific route + Get.toNamed(statItem.route); } } + } class _FinanceStatItem { final IconData icon; @@ -265,3 +287,9 @@ class _FinanceStatItem { _FinanceStatItem(this.icon, this.title, this.color, this.route); } + +class _FinanceCardMeta { + final IconData icon; + final Color color; + _FinanceCardMeta(this.icon, this.color); +} From 0b60276e032df15fbba52c4b6f159fed1f779462 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 10/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 6 + lib/helpers/services/api_service.dart | 54 ++ .../employees/employee_details_model.dart | 4 +- .../employees/employee_detail_screen.dart | 60 ++ lib/view/employees/employees_screen.dart | 167 ++++-- .../manage_reporting_bottom_sheet.dart | 557 ++++++++++++++++++ lib/view/finance/advance_payment_screen.dart | 5 - 7 files changed, 809 insertions(+), 44 deletions(-) create mode 100644 lib/view/employees/manage_reporting_bottom_sheet.dart diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index e73e150..4795c79 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -130,6 +130,12 @@ class ApiEndpoints { static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAdvancePayments = '/Expense/get/transactions'; + // Organization Hierarchy endpoints + static const String getOrganizationHierarchyList = + "/organization/hierarchy/list"; + static const String manageOrganizationHierarchy = + "/organization/hierarchy/manage"; + // Service Project Module API Endpoints static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectDetail = "/serviceproject/details"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 48e5179..d4b7018 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -727,6 +727,60 @@ static Future getServiceProjectDetailApi(String proj } } + /// Fetch hierarchy list for an employee + static Future?> 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> with objects like: + /// { "reportToId": "", "isPrimary": true, "isActive": true } + static Future manageOrganizationHierarchy({ + required String employeeId, + required List> 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 static Future getMasterCurrenciesApi() async { const endpoint = ApiEndpoints.getMasterCurrencies; diff --git a/lib/model/employees/employee_details_model.dart b/lib/model/employees/employee_details_model.dart index b5c22f4..e999836 100644 --- a/lib/model/employees/employee_details_model.dart +++ b/lib/model/employees/employee_details_model.dart @@ -22,7 +22,9 @@ class EmployeeDetailsModel { final bool hasApplicationAccess; final String? organizationId; final String? aadharNumber; - final String? panNumber; + final String? panNumber; + + EmployeeDetailsModel({ required this.id, required this.firstName, diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 81b32de..7450384 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -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/helpers/utils/mixins/ui_mixin.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'; class EmployeeDetailPage extends StatefulWidget { @@ -269,6 +271,64 @@ class _EmployeeDetailPageState extends State with UIMixin { ), 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 _buildSectionCard( title: 'Contact Information', diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 5a6d925..db1f821 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -64,13 +65,17 @@ class _EmployeesScreenState extends State with UIMixin { final searchQuery = query.toLowerCase(); final filtered = query.isEmpty ? List.from(employees) - : employees.where((e) => - e.name.toLowerCase().contains(searchQuery) || - e.email.toLowerCase().contains(searchQuery) || - e.phoneNumber.toLowerCase().contains(searchQuery) || - e.jobRole.toLowerCase().contains(searchQuery), - ).toList(); - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + : employees + .where( + (e) => + e.name.toLowerCase().contains(searchQuery) || + e.email.toLowerCase().contains(searchQuery) || + e.phoneNumber.toLowerCase().contains(searchQuery) || + e.jobRole.toLowerCase().contains(searchQuery), + ) + .toList(); + filtered + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); _filteredEmployees.assignAll(filtered); } @@ -106,7 +111,6 @@ class _EmployeesScreenState extends State with UIMixin { await _refreshEmployees(); } - @override Widget build(BuildContext context) { return Scaffold( @@ -160,7 +164,8 @@ class _EmployeesScreenState extends State with UIMixin { child: Row( children: [ 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'), ), MySpacing.width(8), @@ -206,7 +211,8 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildFloatingActionButton() { return Obx(() { 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(); return InkWell( @@ -218,7 +224,8 @@ class _EmployeesScreenState extends State with UIMixin { color: contentTheme.primary, borderRadius: BorderRadius.circular(28), 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( @@ -235,33 +242,116 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildSearchField() { - return SizedBox( - height: 36, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13, height: 1.2), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - hintText: 'Search employees...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300, width: 1), + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // Search field + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13, height: 1.2), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + 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 - ? GestureDetector( - onTap: () { - _searchController.clear(); - _filterEmployees(''); - }, - child: const Icon(Icons.close, size: 18, color: Colors.grey), - ) - : null, - ), - onChanged: (_) => _filterEmployees(_searchController.text), + MySpacing.width(10), + + // Three dots menu (Manage Reporting) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + itemBuilder: (context) { + List> menuItems = []; + + // Section: Actions + menuItems.add( + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.grey), + ), + ), + ); + + // Manage Reporting option + menuItems.add( + PopupMenuItem( + 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 with UIMixin { return Padding( padding: const EdgeInsets.only(top: 60), 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]), ), ); } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart new file mode 100644 index 0000000..3eb5dd4 --- /dev/null +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -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 createState() => + _ManageReportingBottomSheetState(); +} + +class _ManageReportingBottomSheetState + extends State { + final EmployeesScreenController _employeeController = Get.find(); + final TextEditingController _primaryController = TextEditingController(); + final TextEditingController _secondaryController = TextEditingController(); + + final RxList _filteredPrimary = [].obs; + final RxList _filteredSecondary = [].obs; + final RxList _selectedPrimary = [].obs; + final RxList _selectedSecondary = [].obs; + + final TextEditingController _selectEmployeeController = + TextEditingController(); + final RxList _filteredEmployees = [].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 + ? [] + : 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 + ? [] + : 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 _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 _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> 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 filteredList, + required RxList 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(); + }), + ), + ), + ], + ); + } +} diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index 96d14d3..2c1fe40 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State ), ), ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: () {}, - ), ], ), ); From 818e0a144a5e4b458465e2fc1e27b4588fc448c4 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 17:17:01 +0530 Subject: [PATCH 11/35] implementation of manage reporting inside employee profile --- .../employee/employees_screen_controller.dart | 50 +++++++ .../employees/employee_detail_screen.dart | 71 +++++++++- .../manage_reporting_bottom_sheet.dart | 134 ++++++++++++++---- 3 files changed, 224 insertions(+), 31 deletions(-) diff --git a/lib/controller/employee/employees_screen_controller.dart b/lib/controller/employee/employees_screen_controller.dart index 7d95421..a8d9f1a 100644 --- a/lib/controller/employee/employees_screen_controller.dart +++ b/lib/controller/employee/employees_screen_controller.dart @@ -21,6 +21,10 @@ class EmployeesScreenController extends GetxController { /// ✅ Upload state tracking (if needed later) RxMap uploadingStates = {}.obs; + RxList selectedEmployeePrimaryManagers = [].obs; + RxList selectedEmployeeSecondaryManagers = + [].obs; + @override void onInit() { super.onInit(); @@ -86,6 +90,52 @@ class EmployeesScreenController extends GetxController { isLoadingEmployeeDetails.value = false; } + /// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId + Future fetchReportingManagers(String? employeeId) async { + if (employeeId == null || employeeId.isEmpty) return; + + try { + // ✅ Always clear before new fetch (to avoid mixing old data) + selectedEmployeePrimaryManagers.clear(); + selectedEmployeeSecondaryManagers.clear(); + + // Fetch from existing API helper + final data = await ApiService.getOrganizationHierarchyList(employeeId); + + if (data == null || data.isEmpty) { + update(['employee_screen_controller']); + return; + } + + for (final item in data) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + + final emp = EmployeeModel.fromJson(reportTo); + final isPrimary = item['isPrimary'] == true; + + if (isPrimary) { + if (!selectedEmployeePrimaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeePrimaryManagers.add(emp); + } + } else { + if (!selectedEmployeeSecondaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeeSecondaryManagers.add(emp); + } + } + } catch (_) { + // ignore malformed items + } + } + + update(['employee_screen_controller']); + } catch (e) { + logSafe("Error fetching reporting managers for $employeeId", + level: LogLevel.error, error: e); + } + } + /// 🔹 Clear all employee data void clearEmployees() { employees.clear(); diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 7450384..baa59fa 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -38,6 +38,7 @@ class _EmployeeDetailPageState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { controller.fetchEmployeeDetails(widget.employeeId); + controller.fetchReportingManagers(widget.employeeId); }); } @@ -193,6 +194,7 @@ class _EmployeeDetailPageState extends State with UIMixin { child: MyRefreshIndicator( onRefresh: () async { await controller.fetchEmployeeDetails(widget.employeeId); + await controller.fetchReportingManagers(employee.id); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -298,10 +300,14 @@ class _EmployeeDetailPageState extends State with UIMixin { action: 0, ), hideMainSelector: true, - hideLoggedUserFromSelection: - true, + hideLoggedUserFromSelection: true, + loggedUserId: + controller.selectedEmployeeDetails.value?.id, ), ); + + // 🔄 Refresh reporting managers after editing + await controller.fetchReportingManagers(employee.id); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -326,6 +332,58 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ), ), + Obx(() { + final primary = + controller.selectedEmployeePrimaryManagers; + final secondary = + controller.selectedEmployeeSecondaryManagers; + + if (primary.isEmpty && secondary.isEmpty) { + return const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'No reporting managers assigned', + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only( + top: 8.0, left: 8, right: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Primary → ${_getManagerNames(primary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Secondary → ${_getManagerNames(secondary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }) ], ), @@ -500,4 +558,13 @@ class _EmployeeDetailPageState extends State with UIMixin { }), ); } + + String _getManagerNames(List managers) { + if (managers.isEmpty) return '—'; + return managers + .map((m) => + '${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim()) + .where((name) => name.isNotEmpty) + .join(', '); + } } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index 3eb5dd4..ba035c6 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -13,14 +13,16 @@ class ManageReportingBottomSheet extends StatefulWidget { final EmployeeModel? initialEmployee; final bool hideMainSelector; final bool renderAsCard; - final bool hideLoggedUserFromSelection; // ✅ new + final bool hideLoggedUserFromSelection; + final String? loggedUserId; const ManageReportingBottomSheet({ super.key, this.initialEmployee, this.hideMainSelector = false, this.renderAsCard = false, - this.hideLoggedUserFromSelection = false, // default false + this.hideLoggedUserFromSelection = false, + this.loggedUserId, }); @override @@ -211,21 +213,76 @@ class _ManageReportingBottomSheetState Future _handleSubmit() async { if (_selectedEmployee == null) { showAppSnackbar( - title: 'Error', - message: 'Please select the employee.', - type: SnackbarType.error); + 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); + title: 'Error', + message: 'Please select at least one primary employee.', + type: SnackbarType.error, + ); return; } + final employeeId = _selectedEmployee!.id; + + // === BUILD PAYLOAD (updated logic) === + + // fetch current assignments so we can deactivate old ones + List? currentAssignments; + try { + currentAssignments = + await ApiService.getOrganizationHierarchyList(employeeId); + } catch (_) { + currentAssignments = null; + } + + // helper sets of newly selected ids + final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet(); + final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet(); + final List> payload = []; + // 1) For current active assignments: if they are not in new selections -> add isActive:false + if (currentAssignments != null && currentAssignments.isNotEmpty) { + for (final item in currentAssignments) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + final reportToId = reportTo['id'] as String?; + if (reportToId == null) continue; + final isPrimary = item['isPrimary'] == true; + final currentlyActive = + item['isActive'] == true || item['isActive'] == null; // be safe + + // if currently active and not included in new selection -> mark false + if (currentlyActive) { + if (isPrimary && !newPrimaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": true, + "isActive": false, + }); + } else if (!isPrimary && !newSecondaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": false, + "isActive": false, + }); + } + } + } catch (_) { + // ignore malformed items (same behavior as before) + } + } + } + + // 2) Add new primary (active) for (final emp in _selectedPrimary) { payload.add({ "reportToId": emp.id, @@ -233,6 +290,8 @@ class _ManageReportingBottomSheetState "isActive": true, }); } + + // 3) Add new secondary (active) for (final emp in _selectedSecondary) { payload.add({ "reportToId": emp.id, @@ -242,11 +301,13 @@ class _ManageReportingBottomSheetState } setState(() => _isSubmitting = true); - // show loader - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); - final employeeId = _selectedEmployee!.id; + // show loader + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + final success = await ApiService.manageOrganizationHierarchy( employeeId: employeeId, payload: payload, @@ -254,25 +315,36 @@ class _ManageReportingBottomSheetState // hide loader if (Get.isDialogOpen == true) Get.back(); - setState(() => _isSubmitting = false); if (success) { showAppSnackbar( - title: 'Success', - message: 'Reporting assigned successfully', - type: SnackbarType.success); + title: 'Success', + message: 'Reporting assigned successfully', + type: SnackbarType.success, + ); - // Optionally refresh the saved hierarchy (not necessary here) but we can call: + // ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers + try { + final empId = employeeId; + final EmployeesScreenController controller = Get.find(); + await controller.fetchReportingManagers(empId); + await controller.fetchEmployeeDetails(empId); + } catch (_) { + // ignore if controller not found — not critical + } + + // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset reporter selections for next assignment + // Keep sheet open and reset selections _resetForm(); } else { showAppSnackbar( - title: 'Error', - message: 'Failed to assign reporting. Please try again.', - type: SnackbarType.error); + title: 'Error', + message: 'Failed to assign reporting. Please try again.', + type: SnackbarType.error, + ); } } @@ -354,11 +426,6 @@ class _ManageReportingBottomSheetState backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => setState(() { - _selectedEmployee = null; - _selectEmployeeController.clear(); - _resetReportersOnly(); - }), ), ) else @@ -460,12 +527,21 @@ class _ManageReportingBottomSheetState spacing: 6, runSpacing: 6, children: selectedList.map((emp) { + final isProfileEmployee = widget.initialEmployee != null && + emp.id == widget.initialEmployee!.id; + return Chip( label: Text(emp.name, style: const TextStyle(fontSize: 12)), - backgroundColor: Colors.indigo.shade50, + backgroundColor: isProfileEmployee + ? Colors.indigo.shade50 + : Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), - deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => selectedList.remove(emp), + // Only show delete icon / action for non-profile employees + deleteIcon: isProfileEmployee + ? null + : const Icon(Icons.close, size: 16), + onDeleted: + isProfileEmployee ? null : () => selectedList.remove(emp), ); }).toList(), ); From c4bb8331c981581061c5fc0c10c8b12bb5d9dcf2 Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 11:30:36 +0530 Subject: [PATCH 12/35] .. --- lib/view/employees/manage_reporting_bottom_sheet.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index ba035c6..9bdf9f7 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -331,14 +331,17 @@ class _ManageReportingBottomSheetState await controller.fetchReportingManagers(empId); await controller.fetchEmployeeDetails(empId); } catch (_) { - // ignore if controller not found — not critical + } // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset selections + _resetForm(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } else { showAppSnackbar( title: 'Error', From 214816ac0f0d74ae34f734868cf9d66e1db994d1 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 13 Nov 2025 12:26:39 +0530 Subject: [PATCH 13/35] corrected the payment proceed validation --- .../expense/reimbursement_bottom_sheet.dart | 31 +++++++++++++------ ...ent_request_rembursement_bottom_sheet.dart | 14 +++------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index c2d235c..6ca4d81 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -197,22 +197,33 @@ class _ReimbursementBottomSheetState extends State { return; } - if (expenseTransactionDate != null && - selectedDate.isBefore(expenseTransactionDate)) { - showAppSnackbar( - title: "Invalid Date", - message: - "Reimbursement date cannot be before the transaction date (${DateFormat('yyyy-MM-dd').format(expenseTransactionDate)}).", - type: SnackbarType.warning, + if (expenseTransactionDate != null && selectedDate != null) { + final normalizedSelected = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, ); - return; + final normalizedTransaction = DateTime( + expenseTransactionDate.year, + expenseTransactionDate.month, + expenseTransactionDate.day, + ); + + if (normalizedSelected.isBefore(normalizedTransaction)) { + showAppSnackbar( + title: "Invalid Date", + message: + "Reimbursement date cannot be before the transaction date (${DateFormat('yyyy-MM-dd').format(expenseTransactionDate)}).", + type: SnackbarType.warning, + ); + return; + } } } final success = await widget.onSubmit( comment: commentCtrl.text.trim(), - reimburseTransactionId: txnCtrl.text - .trim(), + reimburseTransactionId: txnCtrl.text.trim(), reimburseDate: dateStr.value, reimburseById: controller.selectedReimbursedBy.value!.id, statusId: widget.statusId, diff --git a/lib/model/finance/payment_request_rembursement_bottom_sheet.dart b/lib/model/finance/payment_request_rembursement_bottom_sheet.dart index 0c39ca6..f8f6983 100644 --- a/lib/model/finance/payment_request_rembursement_bottom_sheet.dart +++ b/lib/model/finance/payment_request_rembursement_bottom_sheet.dart @@ -170,10 +170,11 @@ class _UpdatePaymentRequestWithReimbursementState dateStr.value.isEmpty || baseAmountCtrl.text.trim().isEmpty || taxAmountCtrl.text.trim().isEmpty || - commentCtrl.text.trim().isEmpty) { + commentCtrl.text.trim().isEmpty || + controller.selectedReimbursedBy.value == null) { showAppSnackbar( title: "Incomplete", - message: "Please fill all mandatory fields", + message: "Please fill all mandatory fields, including 'Paid By'", type: SnackbarType.warning, ); return; @@ -233,7 +234,6 @@ class _UpdatePaymentRequestWithReimbursementState decoration: _inputDecoration("Enter transaction ID"), ), MySpacing.height(16), - _requiredLabel("Transaction Date"), MySpacing.height(8), GestureDetector( @@ -266,8 +266,7 @@ class _UpdatePaymentRequestWithReimbursementState ), ), MySpacing.height(16), - - MyText.labelMedium("Paid By (Optional)"), + _requiredLabel("Paid By"), MySpacing.height(8), GestureDetector( onTap: _showEmployeeList, @@ -294,7 +293,6 @@ class _UpdatePaymentRequestWithReimbursementState ), ), MySpacing.height(16), - _requiredLabel("Base Amount"), MySpacing.height(8), TextField( @@ -303,7 +301,6 @@ class _UpdatePaymentRequestWithReimbursementState decoration: _inputDecoration("Enter Base Amount"), ), MySpacing.height(16), - _requiredLabel("GST Amount"), MySpacing.height(8), TextField( @@ -312,7 +309,6 @@ class _UpdatePaymentRequestWithReimbursementState decoration: _inputDecoration("Enter GST Amount"), ), MySpacing.height(16), - MyText.labelMedium("TDS Percent"), MySpacing.height(8), TextField( @@ -335,7 +331,6 @@ class _UpdatePaymentRequestWithReimbursementState color: Colors.grey.shade600, ), MySpacing.height(16), - Obx(() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -353,7 +348,6 @@ class _UpdatePaymentRequestWithReimbursementState ], )), MySpacing.height(20), - _requiredLabel("Comment"), MySpacing.height(8), TextField( From 2255ae29fa1aa527ef75198a4d4c4f0e3a102e8d Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 15:27:13 +0530 Subject: [PATCH 14/35] All Employees fetching task done in advance payment screen --- .../finance/advance_payment_controller.dart | 13 ++- lib/helpers/services/api_service.dart | 81 +++++++++---------- 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/lib/controller/finance/advance_payment_controller.dart b/lib/controller/finance/advance_payment_controller.dart index 1e159fc..84eb917 100644 --- a/lib/controller/finance/advance_payment_controller.dart +++ b/lib/controller/finance/advance_payment_controller.dart @@ -65,12 +65,17 @@ class AdvancePaymentController extends GetxController { try { employeesLoading.value = true; - final list = await ApiService.getEmployees(query: q); + // Build query params + final queryParams = { + 'allEmployee': 'true', + if (q.isNotEmpty) 'q': q, // only include search query if not empty + }; + + final list = await ApiService.getEmployees(queryParams: queryParams); final parsed = Employee.listFromJson(list); - logSafe("✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}"); + logSafe( + "✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}"); - - // Save full result and filter locally allEmployees = parsed; _filterEmployees(q); } catch (e, s) { diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d4b7018..d27bb2a 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -307,34 +307,37 @@ 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"); + 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); + try { + final response = await _getRequest(endpoint); - if (response == null) { - logSafe("Service Project Detail request failed: null response", level: LogLevel.error); - return null; + 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); } - 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; } - return null; -} - /// Get Service Project List static Future getServiceProjectsListApi({ int pageNumber = 1, @@ -1968,34 +1971,22 @@ static Future getServiceProjectDetailApi(String proj } /// Fetch employees with optional query. Returns raw list (List) - static Future> getEmployees({String query = ''}) async { + static Future> getEmployees( + {Map? queryParams}) async { try { - // endpoint relative to ApiEndpoints.baseUrl; _getRequest builds full url - var endpoint = ApiEndpoints.getEmployeesWithoutPermission; - Map? queryParams; - if (query.isNotEmpty) { - // server may expect a query param name other than 'q'. Adjust if needed. - queryParams = {'q': query}; - } + final endpoint = ApiEndpoints.getEmployeesWithoutPermission; final resp = await _getRequest(endpoint, queryParams: queryParams); if (resp == null) return []; - // parse response - try { - final body = jsonDecode(resp.body); - if (body is Map && body.containsKey('data')) { - final data = body['data']; - if (data is List) return data; - return []; - } else if (body is List) { - return body; - } else { - return []; - } - } catch (e, s) { - logSafe("❌ ApiService.getEmployees: parse error $e\n$s", - level: LogLevel.error); + final body = jsonDecode(resp.body); + if (body is Map && body.containsKey('data')) { + final data = body['data']; + if (data is List) return data; + return []; + } else if (body is List) { + return body; + } else { return []; } } catch (e, s) { From 3bc7b5092fe7fb41964e346480994793d920f531 Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 17:59:51 +0530 Subject: [PATCH 15/35] =?UTF-8?q?UI=20Enhancements=20in=20Finance=20Module?= =?UTF-8?q?=20=E2=80=93=20Payment=20Request=20&=20Expense=20Screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/expense/expense_main_components.dart | 16 +++++++++++++++- lib/view/finance/payment_request_screen.dart | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/helpers/widgets/expense/expense_main_components.dart b/lib/helpers/widgets/expense/expense_main_components.dart index 5218943..ae04ff6 100644 --- a/lib/helpers/widgets/expense/expense_main_components.dart +++ b/lib/helpers/widgets/expense/expense_main_components.dart @@ -343,7 +343,21 @@ class ExpenseList extends StatelessWidget { children: [ MyText.bodySmall(formattedDate, fontWeight: 500), const Spacer(), - MyText.bodySmall(expense.status.name, fontWeight: 500), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Color(int.parse( + '0xff${expense.status.color.substring(1)}')) + .withOpacity(0.5), + borderRadius: BorderRadius.circular(5), + ), + child: MyText.bodySmall( + expense.status.name, + color: Colors.white, + fontWeight: 500, + ), + ), ], ), ], diff --git a/lib/view/finance/payment_request_screen.dart b/lib/view/finance/payment_request_screen.dart index 8d1e6bd..417bfd4 100644 --- a/lib/view/finance/payment_request_screen.dart +++ b/lib/view/finance/payment_request_screen.dart @@ -383,7 +383,8 @@ class _PaymentRequestMainScreenState extends State const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Color(int.parse( - '0xff${item.expenseStatus.color.substring(1)}')), + '0xff${item.expenseStatus.color.substring(1)}')) + .withOpacity(0.5), borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( From 1f47a55d9c390d75f8209f2239009ceaf5403101 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 14 Nov 2025 15:35:41 +0530 Subject: [PATCH 16/35] 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], + ), + ), + ], + ); + }, + ), + ], + ), ), ], ), From befeef3c02f16e02b979bd43ab3d195dd50c6fb9 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 14 Nov 2025 15:36:32 +0530 Subject: [PATCH 17/35] removed unused code --- .../add_service_project_job_controller.dart | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/lib/controller/service_project/add_service_project_job_controller.dart b/lib/controller/service_project/add_service_project_job_controller.dart index 6195dab..caa9a6f 100644 --- a/lib/controller/service_project/add_service_project_job_controller.dart +++ b/lib/controller/service_project/add_service_project_job_controller.dart @@ -2,7 +2,6 @@ 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 { @@ -31,7 +30,6 @@ class AddServiceProjectJobController extends GetxController { @override void onInit() { super.onInit(); - searchEmployees(""); // pass empty string safely } @override @@ -42,31 +40,6 @@ class AddServiceProjectJobController extends GetxController { 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) { From 9666d39d5efe749aa1ceaeda23efc02bc88f2c4f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 15 Nov 2025 15:35:56 +0530 Subject: [PATCH 18/35] fixed the model crashing --- .../dashboard/dashboard_tasks_model.dart | 14 ++++++++---- .../dashboard/project_progress_model.dart | 22 +++++++++---------- .../service_projects_list_model.dart | 15 +++++++------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/lib/model/dashboard/dashboard_tasks_model.dart b/lib/model/dashboard/dashboard_tasks_model.dart index ba10a7a..39ac835 100644 --- a/lib/model/dashboard/dashboard_tasks_model.dart +++ b/lib/model/dashboard/dashboard_tasks_model.dart @@ -1,4 +1,3 @@ -// dashboard_tasks_model.dart class DashboardTasks { final bool success; final String message; @@ -23,7 +22,7 @@ class DashboardTasks { data: json['data'] != null ? DashboardTasksData.fromJson(json['data']) : null, errors: json['errors'], statusCode: json['statusCode'] ?? 0, - timestamp: DateTime.parse(json['timestamp']), + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), ); } } @@ -38,9 +37,16 @@ class DashboardTasksData { }); factory DashboardTasksData.fromJson(Map json) { + int toInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; + } + return DashboardTasksData( - totalTasks: json['totalTasks'] ?? 0, - completedTasks: json['completedTasks'] ?? 0, + totalTasks: toInt(json['totalTasks']), + completedTasks: toInt(json['completedTasks']), ); } } diff --git a/lib/model/dashboard/project_progress_model.dart b/lib/model/dashboard/project_progress_model.dart index 20f47c1..e29b71f 100644 --- a/lib/model/dashboard/project_progress_model.dart +++ b/lib/model/dashboard/project_progress_model.dart @@ -27,7 +27,7 @@ class ProjectResponse { [], errors: json['errors'], statusCode: json['statusCode'] ?? 0, - timestamp: DateTime.parse(json['timestamp']), + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), ); } @@ -46,8 +46,8 @@ class ProjectResponse { class ProjectData { final String projectId; final String projectName; - final int plannedTask; - final int completedTask; + final double plannedTask; + final double completedTask; final DateTime date; ProjectData({ @@ -62,9 +62,9 @@ class ProjectData { return ProjectData( projectId: json['projectId'] ?? '', projectName: json['projectName'] ?? '', - plannedTask: json['plannedTask'] ?? 0, - completedTask: json['completedTask'] ?? 0, - date: DateTime.parse(json['date']), + plannedTask: (json['plannedTask'] ?? 0).toDouble(), + completedTask: (json['completedTask'] ?? 0).toDouble(), + date: DateTime.tryParse(json['date'] ?? '') ?? DateTime.now(), ); } @@ -81,8 +81,8 @@ class ProjectData { /// Chart-friendly model class ChartTaskData { - final DateTime date; // ✅ actual date for chart - final String dateLabel; // optional: for display + final DateTime date; + final String dateLabel; final int planned; final int completed; @@ -96,9 +96,9 @@ class ChartTaskData { factory ChartTaskData.fromProjectData(ProjectData data) { return ChartTaskData( date: data.date, - dateLabel: DateFormat('dd-MM').format(data.date), - planned: data.plannedTask, - completed: data.completedTask, + dateLabel: DateFormat('dd-MM').format(data.date), + planned: data.plannedTask.round(), + completed: data.completedTask.round(), ); } } diff --git a/lib/model/service_project/service_projects_list_model.dart b/lib/model/service_project/service_projects_list_model.dart index 7117113..bb1f672 100644 --- a/lib/model/service_project/service_projects_list_model.dart +++ b/lib/model/service_project/service_projects_list_model.dart @@ -108,18 +108,15 @@ class ProjectItem { address: json['address'] ?? '', assignedDate: DateTime.tryParse(json['assignedDate'] ?? '') ?? DateTime.now(), - status: - json['status'] != null ? Status.fromJson(json['status']) : null, - client: - json['client'] != null ? Client.fromJson(json['client']) : null, + status: json['status'] != null ? Status.fromJson(json['status']) : null, + client: json['client'] != null ? Client.fromJson(json['client']) : null, services: json['services'] != null ? List.from(json['services'].map((x) => Service.fromJson(x))) : [], contactName: json['contactName'] ?? '', contactPhone: json['contactPhone'] ?? '', contactEmail: json['contactEmail'] ?? '', - createdAt: - DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), createdBy: json['createdBy'] != null ? CreatedBy.fromJson(json['createdBy']) : null, @@ -192,7 +189,11 @@ class Client { contactPerson: json['contactPerson'] ?? '', address: json['address'] ?? '', contactNumber: json['contactNumber'] ?? '', - sprid: json['sprid'] ?? 0, + sprid: (json['sprid'] is int) + ? json['sprid'] + : (json['sprid'] is double) + ? (json['sprid'] as double).toInt() + : int.tryParse(json['sprid']?.toString() ?? "0") ?? 0, ); } From b0472503d1d20677b5d6e7337d930ba6f0166275 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 19/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 6 + lib/helpers/services/api_service.dart | 54 ++ .../employees/employee_details_model.dart | 4 +- .../employees/employee_detail_screen.dart | 60 ++ lib/view/employees/employees_screen.dart | 167 ++++-- .../manage_reporting_bottom_sheet.dart | 557 ++++++++++++++++++ lib/view/finance/advance_payment_screen.dart | 5 - 7 files changed, 809 insertions(+), 44 deletions(-) create mode 100644 lib/view/employees/manage_reporting_bottom_sheet.dart diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 35e7831..100c1dd 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -130,6 +130,12 @@ class ApiEndpoints { static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAdvancePayments = '/Expense/get/transactions'; + // Organization Hierarchy endpoints + static const String getOrganizationHierarchyList = + "/organization/hierarchy/list"; + static const String manageOrganizationHierarchy = + "/organization/hierarchy/manage"; + // Service Project Module API Endpoints static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectDetail = "/serviceproject/details"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index a5e2219..f2e8fbc 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -850,6 +850,60 @@ class ApiService { } } + /// Fetch hierarchy list for an employee + static Future?> 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> with objects like: + /// { "reportToId": "", "isPrimary": true, "isActive": true } + static Future manageOrganizationHierarchy({ + required String employeeId, + required List> 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 static Future getMasterCurrenciesApi() async { const endpoint = ApiEndpoints.getMasterCurrencies; diff --git a/lib/model/employees/employee_details_model.dart b/lib/model/employees/employee_details_model.dart index b5c22f4..e999836 100644 --- a/lib/model/employees/employee_details_model.dart +++ b/lib/model/employees/employee_details_model.dart @@ -22,7 +22,9 @@ class EmployeeDetailsModel { final bool hasApplicationAccess; final String? organizationId; final String? aadharNumber; - final String? panNumber; + final String? panNumber; + + EmployeeDetailsModel({ required this.id, required this.firstName, diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 81b32de..7450384 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -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/helpers/utils/mixins/ui_mixin.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'; class EmployeeDetailPage extends StatefulWidget { @@ -269,6 +271,64 @@ class _EmployeeDetailPageState extends State with UIMixin { ), 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 _buildSectionCard( title: 'Contact Information', diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 5a6d925..db1f821 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/view/employees/manage_reporting_bottom_sheet.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -64,13 +65,17 @@ class _EmployeesScreenState extends State with UIMixin { final searchQuery = query.toLowerCase(); final filtered = query.isEmpty ? List.from(employees) - : employees.where((e) => - e.name.toLowerCase().contains(searchQuery) || - e.email.toLowerCase().contains(searchQuery) || - e.phoneNumber.toLowerCase().contains(searchQuery) || - e.jobRole.toLowerCase().contains(searchQuery), - ).toList(); - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + : employees + .where( + (e) => + e.name.toLowerCase().contains(searchQuery) || + e.email.toLowerCase().contains(searchQuery) || + e.phoneNumber.toLowerCase().contains(searchQuery) || + e.jobRole.toLowerCase().contains(searchQuery), + ) + .toList(); + filtered + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); _filteredEmployees.assignAll(filtered); } @@ -106,7 +111,6 @@ class _EmployeesScreenState extends State with UIMixin { await _refreshEmployees(); } - @override Widget build(BuildContext context) { return Scaffold( @@ -160,7 +164,8 @@ class _EmployeesScreenState extends State with UIMixin { child: Row( children: [ 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'), ), MySpacing.width(8), @@ -206,7 +211,8 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildFloatingActionButton() { return Obx(() { 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(); return InkWell( @@ -218,7 +224,8 @@ class _EmployeesScreenState extends State with UIMixin { color: contentTheme.primary, borderRadius: BorderRadius.circular(28), 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( @@ -235,33 +242,116 @@ class _EmployeesScreenState extends State with UIMixin { } Widget _buildSearchField() { - return SizedBox( - height: 36, - child: TextField( - controller: _searchController, - style: const TextStyle(fontSize: 13, height: 1.2), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - hintText: 'Search employees...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300, width: 1), + return Padding( + padding: MySpacing.xy(8, 8), + child: Row( + children: [ + // Search field + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13, height: 1.2), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + suffixIcon: ValueListenableBuilder( + 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 - ? GestureDetector( - onTap: () { - _searchController.clear(); - _filterEmployees(''); - }, - child: const Icon(Icons.close, size: 18, color: Colors.grey), - ) - : null, - ), - onChanged: (_) => _filterEmployees(_searchController.text), + MySpacing.width(10), + + // Three dots menu (Manage Reporting) + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: + const Icon(Icons.more_vert, size: 20, color: Colors.black87), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + itemBuilder: (context) { + List> menuItems = []; + + // Section: Actions + menuItems.add( + const PopupMenuItem( + enabled: false, + height: 30, + child: Text( + "Actions", + style: TextStyle( + fontWeight: FontWeight.bold, color: Colors.grey), + ), + ), + ); + + // Manage Reporting option + menuItems.add( + PopupMenuItem( + 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 with UIMixin { return Padding( padding: const EdgeInsets.only(top: 60), 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]), ), ); } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart new file mode 100644 index 0000000..3eb5dd4 --- /dev/null +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -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 createState() => + _ManageReportingBottomSheetState(); +} + +class _ManageReportingBottomSheetState + extends State { + final EmployeesScreenController _employeeController = Get.find(); + final TextEditingController _primaryController = TextEditingController(); + final TextEditingController _secondaryController = TextEditingController(); + + final RxList _filteredPrimary = [].obs; + final RxList _filteredSecondary = [].obs; + final RxList _selectedPrimary = [].obs; + final RxList _selectedSecondary = [].obs; + + final TextEditingController _selectEmployeeController = + TextEditingController(); + final RxList _filteredEmployees = [].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 + ? [] + : 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 + ? [] + : 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 _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 _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> 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 filteredList, + required RxList 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(); + }), + ), + ), + ], + ); + } +} diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index 96d14d3..2c1fe40 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -190,11 +190,6 @@ class _AdvancePaymentScreenState extends State ), ), ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: () {}, - ), ], ), ); From c0b4a74f877972eb6550537f2ed000a05a64ce61 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 17:17:01 +0530 Subject: [PATCH 20/35] implementation of manage reporting inside employee profile --- .../employee/employees_screen_controller.dart | 50 +++++++ .../employees/employee_detail_screen.dart | 71 +++++++++- .../manage_reporting_bottom_sheet.dart | 134 ++++++++++++++---- 3 files changed, 224 insertions(+), 31 deletions(-) diff --git a/lib/controller/employee/employees_screen_controller.dart b/lib/controller/employee/employees_screen_controller.dart index 7d95421..a8d9f1a 100644 --- a/lib/controller/employee/employees_screen_controller.dart +++ b/lib/controller/employee/employees_screen_controller.dart @@ -21,6 +21,10 @@ class EmployeesScreenController extends GetxController { /// ✅ Upload state tracking (if needed later) RxMap uploadingStates = {}.obs; + RxList selectedEmployeePrimaryManagers = [].obs; + RxList selectedEmployeeSecondaryManagers = + [].obs; + @override void onInit() { super.onInit(); @@ -86,6 +90,52 @@ class EmployeesScreenController extends GetxController { isLoadingEmployeeDetails.value = false; } + /// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId + Future fetchReportingManagers(String? employeeId) async { + if (employeeId == null || employeeId.isEmpty) return; + + try { + // ✅ Always clear before new fetch (to avoid mixing old data) + selectedEmployeePrimaryManagers.clear(); + selectedEmployeeSecondaryManagers.clear(); + + // Fetch from existing API helper + final data = await ApiService.getOrganizationHierarchyList(employeeId); + + if (data == null || data.isEmpty) { + update(['employee_screen_controller']); + return; + } + + for (final item in data) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + + final emp = EmployeeModel.fromJson(reportTo); + final isPrimary = item['isPrimary'] == true; + + if (isPrimary) { + if (!selectedEmployeePrimaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeePrimaryManagers.add(emp); + } + } else { + if (!selectedEmployeeSecondaryManagers.any((e) => e.id == emp.id)) { + selectedEmployeeSecondaryManagers.add(emp); + } + } + } catch (_) { + // ignore malformed items + } + } + + update(['employee_screen_controller']); + } catch (e) { + logSafe("Error fetching reporting managers for $employeeId", + level: LogLevel.error, error: e); + } + } + /// 🔹 Clear all employee data void clearEmployees() { employees.clear(); diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index 7450384..baa59fa 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -38,6 +38,7 @@ class _EmployeeDetailPageState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { controller.fetchEmployeeDetails(widget.employeeId); + controller.fetchReportingManagers(widget.employeeId); }); } @@ -193,6 +194,7 @@ class _EmployeeDetailPageState extends State with UIMixin { child: MyRefreshIndicator( onRefresh: () async { await controller.fetchEmployeeDetails(widget.employeeId); + await controller.fetchReportingManagers(employee.id); }, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -298,10 +300,14 @@ class _EmployeeDetailPageState extends State with UIMixin { action: 0, ), hideMainSelector: true, - hideLoggedUserFromSelection: - true, + hideLoggedUserFromSelection: true, + loggedUserId: + controller.selectedEmployeeDetails.value?.id, ), ); + + // 🔄 Refresh reporting managers after editing + await controller.fetchReportingManagers(employee.id); }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -326,6 +332,58 @@ class _EmployeeDetailPageState extends State with UIMixin { ), ), ), + Obx(() { + final primary = + controller.selectedEmployeePrimaryManagers; + final secondary = + controller.selectedEmployeeSecondaryManagers; + + if (primary.isEmpty && secondary.isEmpty) { + return const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'No reporting managers assigned', + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only( + top: 8.0, left: 8, right: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Primary → ${_getManagerNames(primary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'Secondary → ${_getManagerNames(secondary)}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }) ], ), @@ -500,4 +558,13 @@ class _EmployeeDetailPageState extends State with UIMixin { }), ); } + + String _getManagerNames(List managers) { + if (managers.isEmpty) return '—'; + return managers + .map((m) => + '${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim()) + .where((name) => name.isNotEmpty) + .join(', '); + } } diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index 3eb5dd4..ba035c6 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -13,14 +13,16 @@ class ManageReportingBottomSheet extends StatefulWidget { final EmployeeModel? initialEmployee; final bool hideMainSelector; final bool renderAsCard; - final bool hideLoggedUserFromSelection; // ✅ new + final bool hideLoggedUserFromSelection; + final String? loggedUserId; const ManageReportingBottomSheet({ super.key, this.initialEmployee, this.hideMainSelector = false, this.renderAsCard = false, - this.hideLoggedUserFromSelection = false, // default false + this.hideLoggedUserFromSelection = false, + this.loggedUserId, }); @override @@ -211,21 +213,76 @@ class _ManageReportingBottomSheetState Future _handleSubmit() async { if (_selectedEmployee == null) { showAppSnackbar( - title: 'Error', - message: 'Please select the employee.', - type: SnackbarType.error); + 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); + title: 'Error', + message: 'Please select at least one primary employee.', + type: SnackbarType.error, + ); return; } + final employeeId = _selectedEmployee!.id; + + // === BUILD PAYLOAD (updated logic) === + + // fetch current assignments so we can deactivate old ones + List? currentAssignments; + try { + currentAssignments = + await ApiService.getOrganizationHierarchyList(employeeId); + } catch (_) { + currentAssignments = null; + } + + // helper sets of newly selected ids + final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet(); + final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet(); + final List> payload = []; + // 1) For current active assignments: if they are not in new selections -> add isActive:false + if (currentAssignments != null && currentAssignments.isNotEmpty) { + for (final item in currentAssignments) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + final reportToId = reportTo['id'] as String?; + if (reportToId == null) continue; + final isPrimary = item['isPrimary'] == true; + final currentlyActive = + item['isActive'] == true || item['isActive'] == null; // be safe + + // if currently active and not included in new selection -> mark false + if (currentlyActive) { + if (isPrimary && !newPrimaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": true, + "isActive": false, + }); + } else if (!isPrimary && !newSecondaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": false, + "isActive": false, + }); + } + } + } catch (_) { + // ignore malformed items (same behavior as before) + } + } + } + + // 2) Add new primary (active) for (final emp in _selectedPrimary) { payload.add({ "reportToId": emp.id, @@ -233,6 +290,8 @@ class _ManageReportingBottomSheetState "isActive": true, }); } + + // 3) Add new secondary (active) for (final emp in _selectedSecondary) { payload.add({ "reportToId": emp.id, @@ -242,11 +301,13 @@ class _ManageReportingBottomSheetState } setState(() => _isSubmitting = true); - // show loader - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); - final employeeId = _selectedEmployee!.id; + // show loader + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + final success = await ApiService.manageOrganizationHierarchy( employeeId: employeeId, payload: payload, @@ -254,25 +315,36 @@ class _ManageReportingBottomSheetState // hide loader if (Get.isDialogOpen == true) Get.back(); - setState(() => _isSubmitting = false); if (success) { showAppSnackbar( - title: 'Success', - message: 'Reporting assigned successfully', - type: SnackbarType.success); + title: 'Success', + message: 'Reporting assigned successfully', + type: SnackbarType.success, + ); - // Optionally refresh the saved hierarchy (not necessary here) but we can call: + // ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers + try { + final empId = employeeId; + final EmployeesScreenController controller = Get.find(); + await controller.fetchReportingManagers(empId); + await controller.fetchEmployeeDetails(empId); + } catch (_) { + // ignore if controller not found — not critical + } + + // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset reporter selections for next assignment + // Keep sheet open and reset selections _resetForm(); } else { showAppSnackbar( - title: 'Error', - message: 'Failed to assign reporting. Please try again.', - type: SnackbarType.error); + title: 'Error', + message: 'Failed to assign reporting. Please try again.', + type: SnackbarType.error, + ); } } @@ -354,11 +426,6 @@ class _ManageReportingBottomSheetState backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => setState(() { - _selectedEmployee = null; - _selectEmployeeController.clear(); - _resetReportersOnly(); - }), ), ) else @@ -460,12 +527,21 @@ class _ManageReportingBottomSheetState spacing: 6, runSpacing: 6, children: selectedList.map((emp) { + final isProfileEmployee = widget.initialEmployee != null && + emp.id == widget.initialEmployee!.id; + return Chip( label: Text(emp.name, style: const TextStyle(fontSize: 12)), - backgroundColor: Colors.indigo.shade50, + backgroundColor: isProfileEmployee + ? Colors.indigo.shade50 + : Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), - deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => selectedList.remove(emp), + // Only show delete icon / action for non-profile employees + deleteIcon: isProfileEmployee + ? null + : const Icon(Icons.close, size: 16), + onDeleted: + isProfileEmployee ? null : () => selectedList.remove(emp), ); }).toList(), ); From 03b16e0ad93c53806316759af2d5456134a51343 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 21/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 2 - .../employees/employee_detail_screen.dart | 2 + .../manage_reporting_bottom_sheet.dart | 132 ++++-------------- 3 files changed, 30 insertions(+), 106 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 100c1dd..8f9f846 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,8 +2,6 @@ class ApiEndpoints { // static const String baseUrl = "https://stageapi.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://mapi.marcoaiot.com/api"; - static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index baa59fa..6492674 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -13,6 +13,8 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.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/manage_reporting_bottom_sheet.dart'; +import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/view/employees/assign_employee_bottom_sheet.dart'; class EmployeeDetailPage extends StatefulWidget { diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index ba035c6..3eb5dd4 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -13,16 +13,14 @@ class ManageReportingBottomSheet extends StatefulWidget { final EmployeeModel? initialEmployee; final bool hideMainSelector; final bool renderAsCard; - final bool hideLoggedUserFromSelection; - final String? loggedUserId; + final bool hideLoggedUserFromSelection; // ✅ new const ManageReportingBottomSheet({ super.key, this.initialEmployee, this.hideMainSelector = false, this.renderAsCard = false, - this.hideLoggedUserFromSelection = false, - this.loggedUserId, + this.hideLoggedUserFromSelection = false, // default false }); @override @@ -213,76 +211,21 @@ class _ManageReportingBottomSheetState Future _handleSubmit() async { if (_selectedEmployee == null) { showAppSnackbar( - title: 'Error', - message: 'Please select the employee.', - type: SnackbarType.error, - ); + 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, - ); + title: 'Error', + message: 'Please select at least one primary employee.', + type: SnackbarType.error); return; } - final employeeId = _selectedEmployee!.id; - - // === BUILD PAYLOAD (updated logic) === - - // fetch current assignments so we can deactivate old ones - List? currentAssignments; - try { - currentAssignments = - await ApiService.getOrganizationHierarchyList(employeeId); - } catch (_) { - currentAssignments = null; - } - - // helper sets of newly selected ids - final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet(); - final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet(); - final List> payload = []; - // 1) For current active assignments: if they are not in new selections -> add isActive:false - if (currentAssignments != null && currentAssignments.isNotEmpty) { - for (final item in currentAssignments) { - try { - final reportTo = item['reportTo']; - if (reportTo == null) continue; - final reportToId = reportTo['id'] as String?; - if (reportToId == null) continue; - final isPrimary = item['isPrimary'] == true; - final currentlyActive = - item['isActive'] == true || item['isActive'] == null; // be safe - - // if currently active and not included in new selection -> mark false - if (currentlyActive) { - if (isPrimary && !newPrimaryIds.contains(reportToId)) { - payload.add({ - "reportToId": reportToId, - "isPrimary": true, - "isActive": false, - }); - } else if (!isPrimary && !newSecondaryIds.contains(reportToId)) { - payload.add({ - "reportToId": reportToId, - "isPrimary": false, - "isActive": false, - }); - } - } - } catch (_) { - // ignore malformed items (same behavior as before) - } - } - } - - // 2) Add new primary (active) for (final emp in _selectedPrimary) { payload.add({ "reportToId": emp.id, @@ -290,8 +233,6 @@ class _ManageReportingBottomSheetState "isActive": true, }); } - - // 3) Add new secondary (active) for (final emp in _selectedSecondary) { payload.add({ "reportToId": emp.id, @@ -301,13 +242,11 @@ class _ManageReportingBottomSheetState } setState(() => _isSubmitting = true); - // show loader - Get.dialog( - const Center(child: CircularProgressIndicator()), - barrierDismissible: false, - ); + Get.dialog(const Center(child: CircularProgressIndicator()), + barrierDismissible: false); + final employeeId = _selectedEmployee!.id; final success = await ApiService.manageOrganizationHierarchy( employeeId: employeeId, payload: payload, @@ -315,36 +254,25 @@ class _ManageReportingBottomSheetState // hide loader if (Get.isDialogOpen == true) Get.back(); + setState(() => _isSubmitting = false); if (success) { showAppSnackbar( - title: 'Success', - message: 'Reporting assigned successfully', - type: SnackbarType.success, - ); + title: 'Success', + message: 'Reporting assigned successfully', + type: SnackbarType.success); - // ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers - try { - final empId = employeeId; - final EmployeesScreenController controller = Get.find(); - await controller.fetchReportingManagers(empId); - await controller.fetchEmployeeDetails(empId); - } catch (_) { - // ignore if controller not found — not critical - } - - // Optional: re-fetch the organization hierarchy list (if needed elsewhere) + // Optionally refresh the saved hierarchy (not necessary here) but we can call: await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset selections + // 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, - ); + title: 'Error', + message: 'Failed to assign reporting. Please try again.', + type: SnackbarType.error); } } @@ -426,6 +354,11 @@ class _ManageReportingBottomSheetState backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => setState(() { + _selectedEmployee = null; + _selectEmployeeController.clear(); + _resetReportersOnly(); + }), ), ) else @@ -527,21 +460,12 @@ class _ManageReportingBottomSheetState spacing: 6, runSpacing: 6, children: selectedList.map((emp) { - final isProfileEmployee = widget.initialEmployee != null && - emp.id == widget.initialEmployee!.id; - return Chip( label: Text(emp.name, style: const TextStyle(fontSize: 12)), - backgroundColor: isProfileEmployee - ? Colors.indigo.shade50 - : Colors.indigo.shade50, + backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), - // Only show delete icon / action for non-profile employees - deleteIcon: isProfileEmployee - ? null - : const Icon(Icons.close, size: 16), - onDeleted: - isProfileEmployee ? null : () => selectedList.remove(emp), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => selectedList.remove(emp), ); }).toList(), ); From 2d4d71b8478abef9cf605dea157b7b2ddc49406c Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 12:04:35 +0530 Subject: [PATCH 22/35] implementation of Manage reporting --- lib/helpers/services/api_endpoints.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 8f9f846..1a52cb4 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,7 +1,7 @@ class ApiEndpoints { // static const String baseUrl = "https://stageapi.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 getMasterExpensesCategories = From fe4a64e4d2b5c59c77b81fe00061392449994c5b Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 12 Nov 2025 17:17:01 +0530 Subject: [PATCH 23/35] implementation of manage reporting inside employee profile --- .../manage_reporting_bottom_sheet.dart | 134 ++++++++++++++---- 1 file changed, 105 insertions(+), 29 deletions(-) diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index 3eb5dd4..ba035c6 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -13,14 +13,16 @@ class ManageReportingBottomSheet extends StatefulWidget { final EmployeeModel? initialEmployee; final bool hideMainSelector; final bool renderAsCard; - final bool hideLoggedUserFromSelection; // ✅ new + final bool hideLoggedUserFromSelection; + final String? loggedUserId; const ManageReportingBottomSheet({ super.key, this.initialEmployee, this.hideMainSelector = false, this.renderAsCard = false, - this.hideLoggedUserFromSelection = false, // default false + this.hideLoggedUserFromSelection = false, + this.loggedUserId, }); @override @@ -211,21 +213,76 @@ class _ManageReportingBottomSheetState Future _handleSubmit() async { if (_selectedEmployee == null) { showAppSnackbar( - title: 'Error', - message: 'Please select the employee.', - type: SnackbarType.error); + 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); + title: 'Error', + message: 'Please select at least one primary employee.', + type: SnackbarType.error, + ); return; } + final employeeId = _selectedEmployee!.id; + + // === BUILD PAYLOAD (updated logic) === + + // fetch current assignments so we can deactivate old ones + List? currentAssignments; + try { + currentAssignments = + await ApiService.getOrganizationHierarchyList(employeeId); + } catch (_) { + currentAssignments = null; + } + + // helper sets of newly selected ids + final newPrimaryIds = _selectedPrimary.map((e) => e.id).toSet(); + final newSecondaryIds = _selectedSecondary.map((e) => e.id).toSet(); + final List> payload = []; + // 1) For current active assignments: if they are not in new selections -> add isActive:false + if (currentAssignments != null && currentAssignments.isNotEmpty) { + for (final item in currentAssignments) { + try { + final reportTo = item['reportTo']; + if (reportTo == null) continue; + final reportToId = reportTo['id'] as String?; + if (reportToId == null) continue; + final isPrimary = item['isPrimary'] == true; + final currentlyActive = + item['isActive'] == true || item['isActive'] == null; // be safe + + // if currently active and not included in new selection -> mark false + if (currentlyActive) { + if (isPrimary && !newPrimaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": true, + "isActive": false, + }); + } else if (!isPrimary && !newSecondaryIds.contains(reportToId)) { + payload.add({ + "reportToId": reportToId, + "isPrimary": false, + "isActive": false, + }); + } + } + } catch (_) { + // ignore malformed items (same behavior as before) + } + } + } + + // 2) Add new primary (active) for (final emp in _selectedPrimary) { payload.add({ "reportToId": emp.id, @@ -233,6 +290,8 @@ class _ManageReportingBottomSheetState "isActive": true, }); } + + // 3) Add new secondary (active) for (final emp in _selectedSecondary) { payload.add({ "reportToId": emp.id, @@ -242,11 +301,13 @@ class _ManageReportingBottomSheetState } setState(() => _isSubmitting = true); - // show loader - Get.dialog(const Center(child: CircularProgressIndicator()), - barrierDismissible: false); - final employeeId = _selectedEmployee!.id; + // show loader + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + final success = await ApiService.manageOrganizationHierarchy( employeeId: employeeId, payload: payload, @@ -254,25 +315,36 @@ class _ManageReportingBottomSheetState // hide loader if (Get.isDialogOpen == true) Get.back(); - setState(() => _isSubmitting = false); if (success) { showAppSnackbar( - title: 'Success', - message: 'Reporting assigned successfully', - type: SnackbarType.success); + title: 'Success', + message: 'Reporting assigned successfully', + type: SnackbarType.success, + ); - // Optionally refresh the saved hierarchy (not necessary here) but we can call: + // ✅ Refresh both the organization hierarchy and employee details so UI shows updated managers + try { + final empId = employeeId; + final EmployeesScreenController controller = Get.find(); + await controller.fetchReportingManagers(empId); + await controller.fetchEmployeeDetails(empId); + } catch (_) { + // ignore if controller not found — not critical + } + + // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset reporter selections for next assignment + // Keep sheet open and reset selections _resetForm(); } else { showAppSnackbar( - title: 'Error', - message: 'Failed to assign reporting. Please try again.', - type: SnackbarType.error); + title: 'Error', + message: 'Failed to assign reporting. Please try again.', + type: SnackbarType.error, + ); } } @@ -354,11 +426,6 @@ class _ManageReportingBottomSheetState backgroundColor: Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => setState(() { - _selectedEmployee = null; - _selectEmployeeController.clear(); - _resetReportersOnly(); - }), ), ) else @@ -460,12 +527,21 @@ class _ManageReportingBottomSheetState spacing: 6, runSpacing: 6, children: selectedList.map((emp) { + final isProfileEmployee = widget.initialEmployee != null && + emp.id == widget.initialEmployee!.id; + return Chip( label: Text(emp.name, style: const TextStyle(fontSize: 12)), - backgroundColor: Colors.indigo.shade50, + backgroundColor: isProfileEmployee + ? Colors.indigo.shade50 + : Colors.indigo.shade50, labelStyle: TextStyle(color: AppTheme.primaryColor), - deleteIcon: const Icon(Icons.close, size: 16), - onDeleted: () => selectedList.remove(emp), + // Only show delete icon / action for non-profile employees + deleteIcon: isProfileEmployee + ? null + : const Icon(Icons.close, size: 16), + onDeleted: + isProfileEmployee ? null : () => selectedList.remove(emp), ); }).toList(), ); From 831aacc2026075040b321adedf2d8f57f129dbba Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 11:30:36 +0530 Subject: [PATCH 24/35] .. --- lib/view/employees/manage_reporting_bottom_sheet.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/view/employees/manage_reporting_bottom_sheet.dart b/lib/view/employees/manage_reporting_bottom_sheet.dart index ba035c6..9bdf9f7 100644 --- a/lib/view/employees/manage_reporting_bottom_sheet.dart +++ b/lib/view/employees/manage_reporting_bottom_sheet.dart @@ -331,14 +331,17 @@ class _ManageReportingBottomSheetState await controller.fetchReportingManagers(empId); await controller.fetchEmployeeDetails(empId); } catch (_) { - // ignore if controller not found — not critical + } // Optional: re-fetch the organization hierarchy list (if needed elsewhere) await ApiService.getOrganizationHierarchyList(employeeId); - // Keep sheet open and reset selections + _resetForm(); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } else { showAppSnackbar( title: 'Error', From 70c8160dce5275a3705229a561068d8f2e5403cf Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 15:27:13 +0530 Subject: [PATCH 25/35] All Employees fetching task done in advance payment screen --- .../finance/advance_payment_controller.dart | 13 ++-- lib/helpers/services/api_service.dart | 59 +++++++++++-------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/lib/controller/finance/advance_payment_controller.dart b/lib/controller/finance/advance_payment_controller.dart index 1e159fc..84eb917 100644 --- a/lib/controller/finance/advance_payment_controller.dart +++ b/lib/controller/finance/advance_payment_controller.dart @@ -65,12 +65,17 @@ class AdvancePaymentController extends GetxController { try { employeesLoading.value = true; - final list = await ApiService.getEmployees(query: q); + // Build query params + final queryParams = { + 'allEmployee': 'true', + if (q.isNotEmpty) 'q': q, // only include search query if not empty + }; + + final list = await ApiService.getEmployees(queryParams: queryParams); final parsed = Employee.listFromJson(list); - logSafe("✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}"); + logSafe( + "✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}"); - - // Save full result and filter locally allEmployees = parsed; _filterEmployees(q); } catch (e, s) { diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index f2e8fbc..6061bcc 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -427,20 +427,35 @@ class ApiService { } /// 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"); 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); try { final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Detail request failed: null response", + level: LogLevel.error); + return null; + } if (response == null) { logSafe("Service Project Detail request failed: null response", level: LogLevel.error); return null; } + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Detail", + ); final jsonResponse = _parseResponseForAllData( response, label: "Service Project Detail", @@ -454,9 +469,19 @@ class ApiService { level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } + 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; } + return null; + } /// Get Service Project List static Future getServiceProjectsListApi({ @@ -2091,34 +2116,22 @@ class ApiService { } /// Fetch employees with optional query. Returns raw list (List) - static Future> getEmployees({String query = ''}) async { + static Future> getEmployees( + {Map? queryParams}) async { try { - // endpoint relative to ApiEndpoints.baseUrl; _getRequest builds full url - var endpoint = ApiEndpoints.getEmployeesWithoutPermission; - Map? queryParams; - if (query.isNotEmpty) { - // server may expect a query param name other than 'q'. Adjust if needed. - queryParams = {'q': query}; - } + final endpoint = ApiEndpoints.getEmployeesWithoutPermission; final resp = await _getRequest(endpoint, queryParams: queryParams); if (resp == null) return []; - // parse response - try { - final body = jsonDecode(resp.body); - if (body is Map && body.containsKey('data')) { - final data = body['data']; - if (data is List) return data; - return []; - } else if (body is List) { - return body; - } else { - return []; - } - } catch (e, s) { - logSafe("❌ ApiService.getEmployees: parse error $e\n$s", - level: LogLevel.error); + final body = jsonDecode(resp.body); + if (body is Map && body.containsKey('data')) { + final data = body['data']; + if (data is List) return data; + return []; + } else if (body is List) { + return body; + } else { return []; } } catch (e, s) { From 5f99827b23a57a22f59e09ecff6788229c5589fb Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 13 Nov 2025 17:59:51 +0530 Subject: [PATCH 26/35] =?UTF-8?q?UI=20Enhancements=20in=20Finance=20Module?= =?UTF-8?q?=20=E2=80=93=20Payment=20Request=20&=20Expense=20Screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/expense/expense_main_components.dart | 16 +++++++++++++++- lib/view/finance/payment_request_screen.dart | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/helpers/widgets/expense/expense_main_components.dart b/lib/helpers/widgets/expense/expense_main_components.dart index 5218943..ae04ff6 100644 --- a/lib/helpers/widgets/expense/expense_main_components.dart +++ b/lib/helpers/widgets/expense/expense_main_components.dart @@ -343,7 +343,21 @@ class ExpenseList extends StatelessWidget { children: [ MyText.bodySmall(formattedDate, fontWeight: 500), const Spacer(), - MyText.bodySmall(expense.status.name, fontWeight: 500), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Color(int.parse( + '0xff${expense.status.color.substring(1)}')) + .withOpacity(0.5), + borderRadius: BorderRadius.circular(5), + ), + child: MyText.bodySmall( + expense.status.name, + color: Colors.white, + fontWeight: 500, + ), + ), ], ), ], diff --git a/lib/view/finance/payment_request_screen.dart b/lib/view/finance/payment_request_screen.dart index 8d1e6bd..417bfd4 100644 --- a/lib/view/finance/payment_request_screen.dart +++ b/lib/view/finance/payment_request_screen.dart @@ -383,7 +383,8 @@ class _PaymentRequestMainScreenState extends State const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Color(int.parse( - '0xff${item.expenseStatus.color.substring(1)}')), + '0xff${item.expenseStatus.color.substring(1)}')) + .withOpacity(0.5), borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( From 31a27da85d92bd5cccb66775248d3315b3b18c2c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:08:50 +0530 Subject: [PATCH 27/35] added service project job details screen --- ...ice_project_details_screen_controller.dart | 38 +- lib/helpers/services/api_endpoints.dart | 4 +- lib/helpers/services/api_service.dart | 30 ++ lib/helpers/widgets/avatar.dart | 75 ++-- lib/helpers/widgets/custom_app_bar.dart | 119 +++--- .../service_project_job_detail_model.dart | 244 +++++++++++++ .../service_project_details_screen.dart | 268 ++++++-------- .../service_project_job_detail_screen.dart | 341 ++++++++++++++++++ .../service_project_screen.dart | 63 +--- 9 files changed, 869 insertions(+), 313 deletions(-) create mode 100644 lib/model/service_project/service_project_job_detail_model.dart create mode 100644 lib/view/service_project/service_project_job_detail_screen.dart 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 2761723..6f70896 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -2,6 +2,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'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ServiceProjectDetailsController extends GetxController { // Selected project id @@ -13,13 +14,18 @@ class ServiceProjectDetailsController extends GetxController { // Job list var jobList = [].obs; + // Job detail for a selected job + var jobDetail = Rxn(); + // Loading states var isLoading = false.obs; var isJobLoading = false.obs; + var isJobDetailLoading = false.obs; // Error messages var errorMessage = ''.obs; var jobErrorMessage = ''.obs; + var jobDetailErrorMessage = ''.obs; // Pagination var pageNumber = 1; @@ -53,14 +59,12 @@ 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"; @@ -83,7 +87,7 @@ class ServiceProjectDetailsController extends GetxController { try { final result = await ApiService.getServiceProjectJobListApi( - projectId: "", + projectId: projectId.value, pageNumber: pageNumber, pageSize: pageSize, isActive: true, @@ -122,4 +126,28 @@ class ServiceProjectDetailsController extends GetxController { fetchProjectJobs(initialLoad: true), ]); } + + /// Fetch job details by job ID + Future fetchJobDetail(String jobId) async { + if (jobId.isEmpty) { + jobDetailErrorMessage.value = "Invalid job ID"; + return; + } + + isJobDetailLoading.value = true; + jobDetailErrorMessage.value = ''; + + try { + final result = await ApiService.getServiceProjectJobDetailApi(jobId); + if (result != null) { + jobDetail.value = result; + } else { + jobDetailErrorMessage.value = "Failed to fetch job details"; + } + } catch (e) { + jobDetailErrorMessage.value = "Error fetching job details: $e"; + } finally { + isJobDetailLoading.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 35e7831..d4fae11 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,8 +1,8 @@ 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://devapi.marcoaiot.com/api"; - static const String baseUrl = "https://mapi.marcoaiot.com/api"; + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index a5e2219..923baa0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -36,6 +36,7 @@ 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'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ApiService { static const bool enableLogs = true; @@ -307,6 +308,35 @@ class ApiService { // Service Project Module APIs + +/// Get details for a single Service Project Job +static Future getServiceProjectJobDetailApi(String jobId) async { + final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; + logSafe("Fetching Job Detail for Job ID: $jobId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Job Detail request failed: null response", level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job Detail", + ); + + if (jsonResponse != null) { + return JobDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobDetailApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; +} + /// Create a new Service Project Job static Future createServiceProjectJobApi({ required String title, diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index fdb32de..c206631 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -5,14 +5,16 @@ import 'package:marco/helpers/widgets/my_text.dart'; class Avatar extends StatelessWidget { final String firstName; final String lastName; + final String? imageUrl; final double size; - final Color? backgroundColor; + final Color? backgroundColor; final Color textColor; const Avatar({ super.key, required this.firstName, required this.lastName, + this.imageUrl, this.size = 46.0, this.backgroundColor, this.textColor = Colors.white, @@ -20,9 +22,24 @@ class Avatar extends StatelessWidget { @override Widget build(BuildContext context) { - String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); + if (imageUrl != null && imageUrl!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: Image.network( + imageUrl!, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } - final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); + String initials = + "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}" + .toUpperCase(); + + final Color bgColor = + backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); return MyContainer.rounded( height: size, @@ -32,36 +49,36 @@ class Avatar extends StatelessWidget { child: Center( child: MyText( initials, - fontSize: size * 0.45, // 👈 scales with avatar size + fontSize: size * 0.45, fontWeight: 600, color: textColor, ), ), ); } - - // Use fixed flat color palette and pick based on hash - Color _getFlatColorFromName(String name) { - final colors = [ - Color(0xFFE57373), // Red - Color(0xFFF06292), // Pink - Color(0xFFBA68C8), // Purple - Color(0xFF9575CD), // Deep Purple - Color(0xFF7986CB), // Indigo - Color(0xFF64B5F6), // Blue - Color(0xFF4FC3F7), // Light Blue - Color(0xFF4DD0E1), // Cyan - Color(0xFF4DB6AC), // Teal - Color(0xFF81C784), // Green - Color(0xFFAED581), // Light Green - Color(0xFFDCE775), // Lime - Color(0xFFFFD54F), // Amber - Color(0xFFFFB74D), // Orange - Color(0xFFA1887F), // Brown - Color(0xFF90A4AE), // Blue Grey - ]; - - int index = name.hashCode.abs() % colors.length; - return colors[index]; - } +} + +// Use fixed flat color palette and pick based on hash +Color _getFlatColorFromName(String name) { + final colors = [ + Color(0xFFE57373), // Red + Color(0xFFF06292), // Pink + Color(0xFFBA68C8), // Purple + Color(0xFF9575CD), // Deep Purple + Color(0xFF7986CB), // Indigo + Color(0xFF64B5F6), // Blue + Color(0xFF4FC3F7), // Light Blue + Color(0xFF4DD0E1), // Cyan + Color(0xFF4DB6AC), // Teal + Color(0xFF81C784), // Green + Color(0xFFAED581), // Light Green + Color(0xFFDCE775), // Lime + Color(0xFFFFD54F), // Amber + Color(0xFFFFB74D), // Orange + Color(0xFFA1887F), // Brown + Color(0xFF90A4AE), // Blue Grey + ]; + + int index = name.hashCode.abs() % colors.length; + return colors[index]; } diff --git a/lib/helpers/widgets/custom_app_bar.dart b/lib/helpers/widgets/custom_app_bar.dart index fdd6d5c..0b39ad0 100644 --- a/lib/helpers/widgets/custom_app_bar.dart +++ b/lib/helpers/widgets/custom_app_bar.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; @@ -17,67 +18,67 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return PreferredSize( preferredSize: const Size.fromHeight(72), - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 0.5, - offset: const Offset(0, 0.5), - ) - ], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: onBackPressed ?? Get.back, - splashRadius: 24, + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + color: Colors.black, + size: 20, ), - const SizedBox(width: 8), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge( - title, - fontWeight: 700, - color: Colors.black, - ), - const SizedBox(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), - const SizedBox(width: 4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), + onPressed: onBackPressed ?? () => Get.back(), + ), + MySpacing.width(5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // TITLE + MyText.titleLarge( + title, + fontWeight: 700, + color: Colors.black, + ), + + MySpacing.height(2), + + // PROJECT NAME ROW (copied exactly) + 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], ), - ], - ); - }, - ), - ], - ), + ), + ], + ); + }, + ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/model/service_project/service_project_job_detail_model.dart b/lib/model/service_project/service_project_job_detail_model.dart new file mode 100644 index 0000000..a74f309 --- /dev/null +++ b/lib/model/service_project/service_project_job_detail_model.dart @@ -0,0 +1,244 @@ +class JobDetailsResponse { + final bool success; + final String message; + final JobData? data; + final dynamic errors; + final int statusCode; + final String timestamp; + + JobDetailsResponse({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory JobDetailsResponse.fromJson(Map json) { + return JobDetailsResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: json['data'] != null ? JobData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: json['timestamp'] as String, + ); + } +} + +class JobData { + 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 User createdBy; + final List tags; + final List updateLogs; + + JobData({ + required this.id, + required this.title, + required this.description, + required this.project, + required this.assignees, + required this.status, + required this.startDate, + required this.dueDate, + required this.isActive, + required this.createdAt, + required this.createdBy, + required this.tags, + required this.updateLogs, + }); + + factory JobData.fromJson(Map json) { + return JobData( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + project: Project.fromJson(json['project']), + assignees: (json['assignees'] as List) + .map((e) => Assignee.fromJson(e)) + .toList(), + status: Status.fromJson(json['status']), + startDate: json['startDate'] as String, + dueDate: json['dueDate'] as String, + isActive: json['isActive'] as bool, + createdAt: json['createdAt'] as String, + createdBy: User.fromJson(json['createdBy']), + tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + updateLogs: (json['updateLogs'] as List) + .map((e) => UpdateLog.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'] as String, + name: json['name'] as String, + shortName: json['shortName'] as String, + assignedDate: json['assignedDate'] as String, + contactName: json['contactName'] as String, + contactPhone: json['contactPhone'] as String, + contactEmail: json['contactEmail'] as String, + ); + } +} + +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'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +class Status { + final String id; + final String name; + final String displayName; + final int level; + + Status({ + required this.id, + required this.name, + required this.displayName, + required this.level, + }); + + factory Status.fromJson(Map json) { + return Status( + id: json['id'] as String, + name: json['name'] as String, + displayName: json['displayName'] as String, + level: json['level'] as int, + ); + } +} + +class User { + final String id; + final String firstName; + final String lastName; + final String email; + final String photo; + final String jobRoleId; + final String jobRoleName; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +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'] as String, + name: json['name'] as String, + ); + } +} + +class UpdateLog { + final String id; + final Status? status; + final Status nextStatus; + final String comment; + final User updatedBy; + + UpdateLog({ + required this.id, + this.status, + required this.nextStatus, + required this.comment, + required this.updatedBy, + }); + + factory UpdateLog.fromJson(Map json) { + return UpdateLog( + id: json['id'] as String, + status: json['status'] != null ? Status.fromJson(json['status']) : null, + nextStatus: Status.fromJson(json['nextStatus']), + comment: json['comment'] as String, + updatedBy: User.fromJson(json['updatedBy']), + ); + } +} diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index ad39ef7..6e1dc36 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; -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'; @@ -9,6 +8,9 @@ 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'; +import 'package:marco/view/service_project/service_project_job_detail_screen.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -345,115 +347,118 @@ class _ServiceProjectDetailsScreenState } 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(), - ), + return InkWell( + onTap: () { + Get.to(() => JobDetailsScreen(jobId: job.id)); + }, + child: 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: [ + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(6), + MyText.bodySmall( + job.description.isNotEmpty + ? job.description + : "No description provided", + color: Colors.grey[700], ), - 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), + // 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(), + ), ), - const Spacer(), + MySpacing.height(8), - // 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), + // Assignees & Status + Row( + children: [ + if (job.assignees != null && job.assignees!.isNotEmpty) + ...job.assignees!.map((assignee) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: Avatar( + firstName: assignee.firstName, + lastName: assignee.lastName, + size: + 24, + imageUrl: assignee.photo.isNotEmpty + ? assignee.photo + : 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), ), - child: Text( - job.status.displayName, - style: TextStyle( - fontSize: 12, + + const Spacer(), + + // Status Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( color: job.status.name.toLowerCase() == 'completed' - ? Colors.green[800] - : Colors.orange[800], - fontWeight: FontWeight.w600, + ? 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, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); @@ -466,64 +471,9 @@ class _ServiceProjectDetailsScreenState Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.toNamed('/dashboard/service-projects'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Service Project Details', - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Project Details", + onBackPressed: () => Get.toNamed('/dashboard/service-projects'), ), body: SafeArea( child: Column( diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart new file mode 100644 index 0000000..09c2990 --- /dev/null +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/service_project/service_project_details_screen_controller.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/utils/date_time_utils.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:timeline_tile/timeline_tile.dart'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; + +class JobDetailsScreen extends StatefulWidget { + final String jobId; + + const JobDetailsScreen({super.key, required this.jobId}); + + @override + State createState() => _JobDetailsScreenState(); +} + +class _JobDetailsScreenState extends State with UIMixin { + late final ServiceProjectDetailsController controller; + + @override + void initState() { + super.initState(); + controller = Get.put(ServiceProjectDetailsController()); + controller.fetchJobDetail(widget.jobId); + } + + Widget _buildSectionCard({ + required String title, + required IconData titleIcon, + required List children, + }) { + return Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(titleIcon, size: 20), + MySpacing.width(8), + MyText.bodyLarge( + title, + fontWeight: 700, + fontSize: 16, + ) + ], + ), + MySpacing.height(8), + const Divider(), + ...children + ], + ), + ), + ); + } + + Widget _rowTile(String label, String value, {bool copyable = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: MyText.bodySmall(label, + color: Colors.grey[600], fontWeight: 600), + ), + Expanded( + flex: 5, + child: GestureDetector( + onLongPress: copyable + ? () => LauncherUtils.copyToClipboard(value, typeLabel: label) + : null, + child: MyText.bodyMedium(value, + fontWeight: 600, + color: copyable ? Colors.blue : Colors.black87), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: CustomAppBar( + title: "Service Project Job Details", + onBackPressed: () => Get.back(), + ), + body: Obx(() { + if (controller.isJobDetailLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.jobDetailErrorMessage.value.isNotEmpty) { + return Center( + child: MyText.bodyMedium(controller.jobDetailErrorMessage.value), + ); + } + + final job = controller.jobDetail.value?.data; + if (job == null) { + return Center(child: MyText.bodyMedium("No details available")); + } + + return SingleChildScrollView( + padding: MySpacing.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ====== HEADER CARD ======= + Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.task_outlined, size: 35), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(5), + MyText.bodySmall(job.project.name, + color: Colors.grey[700]), + ], + ), + ) + ], + ), + ), + ), + + MySpacing.height(20), + + // ====== Job Information ======= + _buildSectionCard( + title: "Job Information", + titleIcon: Icons.info_outline, + children: [ + _rowTile("Description", job.description), + _rowTile( + "Start Date", + DateTimeUtils.convertUtcToLocal(job.startDate, + format: "dd MMM yyyy"), + ), + _rowTile( + "Due Date", + DateTimeUtils.convertUtcToLocal(job.dueDate, + format: "dd MMM yyyy"), + ), + _rowTile("Status", job.status.displayName), + ], + ), + + MySpacing.height(16), + + // ====== Assignees ======= + _buildSectionCard( + title: "Assigned To", + titleIcon: Icons.people_outline, + children: job.assignees.isNotEmpty + ? job.assignees.map((a) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Avatar( + firstName: a.firstName, + lastName: a.lastName, + size: + 32, + backgroundColor: + a.photo.isEmpty ? null : Colors.transparent, + textColor: Colors.white, + ), + MySpacing.width(10), + MyText.bodyMedium("${a.firstName} ${a.lastName}"), + ], + ), + ); + }).toList() + : [MyText.bodySmall("No assignees", color: Colors.grey)], + ), + + MySpacing.height(16), + + // ====== Tags ======= + if (job.tags.isNotEmpty) + _buildSectionCard( + title: "Tags", + titleIcon: Icons.label_outline, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: job.tags.map((tag) { + return Chip( + label: Text(tag.name), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ); + }).toList(), + ) + ], + ), + + MySpacing.height(16), + + // ====== Update Logs (Timeline UI) ======= + if (job.updateLogs.isNotEmpty) + _buildSectionCard( + title: "Update Logs", + titleIcon: Icons.history, + children: [ + JobTimeline(logs: job.updateLogs), + ], + ), + + MySpacing.height(40), + ], + ), + ); + }), + ); + } +} + +class JobTimeline extends StatelessWidget { + final List logs; + + const JobTimeline({super.key, required this.logs}); + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) { + return MyText.bodyMedium('No timeline available', color: Colors.grey); + } + + // Show latest updates at the top + final reversedLogs = logs.reversed.toList(); + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: reversedLogs.length, + itemBuilder: (_, index) { + final log = reversedLogs[index]; + + final statusName = log.status?.displayName ?? "Created"; + final nextStatusName = log.nextStatus.displayName; + final comment = log.comment; + + final updatedBy = + "${log.updatedBy.firstName} ${log.updatedBy.lastName}"; + + final initials = + "${log.updatedBy.firstName.isNotEmpty ? log.updatedBy.firstName[0] : ''}" + "${log.updatedBy.lastName.isNotEmpty ? log.updatedBy.lastName[0] : ''}"; + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: index == 0, + isLast: index == reversedLogs.length - 1, + indicatorStyle: IndicatorStyle( + width: 16, + height: 16, + indicator: Container( + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + ), + beforeLineStyle: LineStyle( + color: Colors.grey.shade300, + thickness: 2, + ), + endChild: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // STATUS CHANGE ROW + MyText.bodyMedium( + "$statusName → $nextStatusName", + fontWeight: 600, + ), + const SizedBox(height: 8), + + // COMMENT + if (comment.isNotEmpty) MyText.bodyMedium(comment), + + const SizedBox(height: 10), + + // Updated by + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall(initials, fontWeight: 600), + ), + const SizedBox(width: 6), + Expanded( + child: MyText.bodySmall(updatedBy), + ), + ], + ), + + const SizedBox(height: 10), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index 8831369..50bdee7 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -8,7 +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'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; class ServiceProjectScreen extends StatefulWidget { const ServiceProjectScreen({super.key}); @@ -194,64 +194,9 @@ class _ServiceProjectScreenState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.toNamed('/dashboard'), - ), - MySpacing.width(8), - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Projects", + onBackPressed: () => Get.toNamed('/dashboard'), ), body: Column( children: [ From ee1e5014b407f85f1e584a0a446d59b910dc3ab5 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:15:17 +0530 Subject: [PATCH 28/35] added edit job api --- lib/helpers/services/api_endpoints.dart | 7 +- lib/helpers/services/api_service.dart | 98 ++++++++++++++++++------- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index d4fae11..49ae63a 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,8 +2,7 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.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://mapi.marcoaiot.com/api"; - + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = @@ -134,6 +133,8 @@ class ApiEndpoints { 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 getServiceProjectJobDetail = + "/serviceproject/job/details"; + static const String editServiceProjectJob = "/serviceproject/job/edit"; static const String createServiceProjectJob = "/serviceproject/job/create"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 923baa0..55949f4 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -307,35 +307,83 @@ class ApiService { } // Service Project Module APIs + /// Edit a Service Project Job + static Future editServiceProjectJobApi({ + required String jobId, + required List> + operations, + }) async { + final endpoint = "${ApiEndpoints.editServiceProjectJob}/$jobId"; + logSafe("Editing Service Project Job: $jobId with operations: $operations"); -/// Get details for a single Service Project Job -static Future getServiceProjectJobDetailApi(String jobId) async { - final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; - logSafe("Fetching Job Detail for Job ID: $jobId"); - - try { - final response = await _getRequest(endpoint); - if (response == null) { - logSafe("Service Project Job Detail request failed: null response", level: LogLevel.error); - return null; + try { + // PATCH request is usually similar to PUT, but with http.patch + String? token = await _getToken(); + if (token == null) return false; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + final headers = _headers(token); + + final response = await http + .patch(uri, headers: headers, body: jsonEncode(operations)) + .timeout(extendedTimeout); + + logSafe( + "Edit Service Project Job response status: ${response.statusCode}"); + logSafe("Edit Service Project Job response body: ${response.body}"); + + final json = jsonDecode(response.body); + + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Service Project Job edited successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to edit Service Project Job: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during editServiceProjectJobApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; } - - final jsonResponse = _parseResponseForAllData( - response, - label: "Service Project Job Detail", - ); - - if (jsonResponse != null) { - return JobDetailsResponse.fromJson(jsonResponse); - } - } catch (e, stack) { - logSafe("Exception during getServiceProjectJobDetailApi: $e", level: LogLevel.error); - logSafe("StackTrace: $stack", level: LogLevel.debug); } - - return null; -} + + /// Get details for a single Service Project Job + static Future getServiceProjectJobDetailApi( + String jobId) async { + final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; + logSafe("Fetching Job Detail for Job ID: $jobId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Job Detail request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job Detail", + ); + + if (jsonResponse != null) { + return JobDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobDetailApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } /// Create a new Service Project Job static Future createServiceProjectJobApi({ From 6e37e0dd042ef28963bf667ae7bd630c9a36f495 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 15 Nov 2025 15:35:56 +0530 Subject: [PATCH 29/35] fixed the model crashing --- .../dashboard/dashboard_tasks_model.dart | 14 ++++++++---- .../dashboard/project_progress_model.dart | 22 +++++++++---------- .../service_projects_list_model.dart | 15 +++++++------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/lib/model/dashboard/dashboard_tasks_model.dart b/lib/model/dashboard/dashboard_tasks_model.dart index ba10a7a..39ac835 100644 --- a/lib/model/dashboard/dashboard_tasks_model.dart +++ b/lib/model/dashboard/dashboard_tasks_model.dart @@ -1,4 +1,3 @@ -// dashboard_tasks_model.dart class DashboardTasks { final bool success; final String message; @@ -23,7 +22,7 @@ class DashboardTasks { data: json['data'] != null ? DashboardTasksData.fromJson(json['data']) : null, errors: json['errors'], statusCode: json['statusCode'] ?? 0, - timestamp: DateTime.parse(json['timestamp']), + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), ); } } @@ -38,9 +37,16 @@ class DashboardTasksData { }); factory DashboardTasksData.fromJson(Map json) { + int toInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; + } + return DashboardTasksData( - totalTasks: json['totalTasks'] ?? 0, - completedTasks: json['completedTasks'] ?? 0, + totalTasks: toInt(json['totalTasks']), + completedTasks: toInt(json['completedTasks']), ); } } diff --git a/lib/model/dashboard/project_progress_model.dart b/lib/model/dashboard/project_progress_model.dart index 20f47c1..e29b71f 100644 --- a/lib/model/dashboard/project_progress_model.dart +++ b/lib/model/dashboard/project_progress_model.dart @@ -27,7 +27,7 @@ class ProjectResponse { [], errors: json['errors'], statusCode: json['statusCode'] ?? 0, - timestamp: DateTime.parse(json['timestamp']), + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), ); } @@ -46,8 +46,8 @@ class ProjectResponse { class ProjectData { final String projectId; final String projectName; - final int plannedTask; - final int completedTask; + final double plannedTask; + final double completedTask; final DateTime date; ProjectData({ @@ -62,9 +62,9 @@ class ProjectData { return ProjectData( projectId: json['projectId'] ?? '', projectName: json['projectName'] ?? '', - plannedTask: json['plannedTask'] ?? 0, - completedTask: json['completedTask'] ?? 0, - date: DateTime.parse(json['date']), + plannedTask: (json['plannedTask'] ?? 0).toDouble(), + completedTask: (json['completedTask'] ?? 0).toDouble(), + date: DateTime.tryParse(json['date'] ?? '') ?? DateTime.now(), ); } @@ -81,8 +81,8 @@ class ProjectData { /// Chart-friendly model class ChartTaskData { - final DateTime date; // ✅ actual date for chart - final String dateLabel; // optional: for display + final DateTime date; + final String dateLabel; final int planned; final int completed; @@ -96,9 +96,9 @@ class ChartTaskData { factory ChartTaskData.fromProjectData(ProjectData data) { return ChartTaskData( date: data.date, - dateLabel: DateFormat('dd-MM').format(data.date), - planned: data.plannedTask, - completed: data.completedTask, + dateLabel: DateFormat('dd-MM').format(data.date), + planned: data.plannedTask.round(), + completed: data.completedTask.round(), ); } } diff --git a/lib/model/service_project/service_projects_list_model.dart b/lib/model/service_project/service_projects_list_model.dart index 7117113..bb1f672 100644 --- a/lib/model/service_project/service_projects_list_model.dart +++ b/lib/model/service_project/service_projects_list_model.dart @@ -108,18 +108,15 @@ class ProjectItem { address: json['address'] ?? '', assignedDate: DateTime.tryParse(json['assignedDate'] ?? '') ?? DateTime.now(), - status: - json['status'] != null ? Status.fromJson(json['status']) : null, - client: - json['client'] != null ? Client.fromJson(json['client']) : null, + status: json['status'] != null ? Status.fromJson(json['status']) : null, + client: json['client'] != null ? Client.fromJson(json['client']) : null, services: json['services'] != null ? List.from(json['services'].map((x) => Service.fromJson(x))) : [], contactName: json['contactName'] ?? '', contactPhone: json['contactPhone'] ?? '', contactEmail: json['contactEmail'] ?? '', - createdAt: - DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), createdBy: json['createdBy'] != null ? CreatedBy.fromJson(json['createdBy']) : null, @@ -192,7 +189,11 @@ class Client { contactPerson: json['contactPerson'] ?? '', address: json['address'] ?? '', contactNumber: json['contactNumber'] ?? '', - sprid: json['sprid'] ?? 0, + sprid: (json['sprid'] is int) + ? json['sprid'] + : (json['sprid'] is double) + ? (json['sprid'] as double).toInt() + : int.tryParse(json['sprid']?.toString() ?? "0") ?? 0, ); } From 9ef1f57ca4eb08c80037410554a93553b4235111 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:08:50 +0530 Subject: [PATCH 30/35] added service project job details screen --- ...ice_project_details_screen_controller.dart | 38 +- lib/helpers/services/api_endpoints.dart | 7 +- lib/helpers/services/api_service.dart | 30 ++ lib/helpers/widgets/avatar.dart | 75 ++-- lib/helpers/widgets/custom_app_bar.dart | 119 +++--- .../service_project_job_detail_model.dart | 244 +++++++++++++ .../service_project_details_screen.dart | 268 ++++++-------- .../service_project_job_detail_screen.dart | 341 ++++++++++++++++++ .../service_project_screen.dart | 63 +--- 9 files changed, 871 insertions(+), 314 deletions(-) create mode 100644 lib/model/service_project/service_project_job_detail_model.dart create mode 100644 lib/view/service_project/service_project_job_detail_screen.dart 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 2761723..6f70896 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -2,6 +2,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'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ServiceProjectDetailsController extends GetxController { // Selected project id @@ -13,13 +14,18 @@ class ServiceProjectDetailsController extends GetxController { // Job list var jobList = [].obs; + // Job detail for a selected job + var jobDetail = Rxn(); + // Loading states var isLoading = false.obs; var isJobLoading = false.obs; + var isJobDetailLoading = false.obs; // Error messages var errorMessage = ''.obs; var jobErrorMessage = ''.obs; + var jobDetailErrorMessage = ''.obs; // Pagination var pageNumber = 1; @@ -53,14 +59,12 @@ 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"; @@ -83,7 +87,7 @@ class ServiceProjectDetailsController extends GetxController { try { final result = await ApiService.getServiceProjectJobListApi( - projectId: "", + projectId: projectId.value, pageNumber: pageNumber, pageSize: pageSize, isActive: true, @@ -122,4 +126,28 @@ class ServiceProjectDetailsController extends GetxController { fetchProjectJobs(initialLoad: true), ]); } + + /// Fetch job details by job ID + Future fetchJobDetail(String jobId) async { + if (jobId.isEmpty) { + jobDetailErrorMessage.value = "Invalid job ID"; + return; + } + + isJobDetailLoading.value = true; + jobDetailErrorMessage.value = ''; + + try { + final result = await ApiService.getServiceProjectJobDetailApi(jobId); + if (result != null) { + jobDetail.value = result; + } else { + jobDetailErrorMessage.value = "Failed to fetch job details"; + } + } catch (e) { + jobDetailErrorMessage.value = "Error fetching job details: $e"; + } finally { + isJobDetailLoading.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 4ee46e4..04823ee 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,8 +1,9 @@ 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://stageapi.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 baseUrl = "https://mapi.marcoaiot.com/api"; + static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 6061bcc..cb74f50 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -36,6 +36,7 @@ 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'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ApiService { static const bool enableLogs = true; @@ -307,6 +308,35 @@ class ApiService { // Service Project Module APIs + +/// Get details for a single Service Project Job +static Future getServiceProjectJobDetailApi(String jobId) async { + final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; + logSafe("Fetching Job Detail for Job ID: $jobId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Job Detail request failed: null response", level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job Detail", + ); + + if (jsonResponse != null) { + return JobDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobDetailApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; +} + /// Create a new Service Project Job static Future createServiceProjectJobApi({ required String title, diff --git a/lib/helpers/widgets/avatar.dart b/lib/helpers/widgets/avatar.dart index fdb32de..c206631 100644 --- a/lib/helpers/widgets/avatar.dart +++ b/lib/helpers/widgets/avatar.dart @@ -5,14 +5,16 @@ import 'package:marco/helpers/widgets/my_text.dart'; class Avatar extends StatelessWidget { final String firstName; final String lastName; + final String? imageUrl; final double size; - final Color? backgroundColor; + final Color? backgroundColor; final Color textColor; const Avatar({ super.key, required this.firstName, required this.lastName, + this.imageUrl, this.size = 46.0, this.backgroundColor, this.textColor = Colors.white, @@ -20,9 +22,24 @@ class Avatar extends StatelessWidget { @override Widget build(BuildContext context) { - String initials = "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}".toUpperCase(); + if (imageUrl != null && imageUrl!.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(size / 2), + child: Image.network( + imageUrl!, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } - final Color bgColor = backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); + String initials = + "${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}" + .toUpperCase(); + + final Color bgColor = + backgroundColor ?? _getFlatColorFromName('$firstName$lastName'); return MyContainer.rounded( height: size, @@ -32,36 +49,36 @@ class Avatar extends StatelessWidget { child: Center( child: MyText( initials, - fontSize: size * 0.45, // 👈 scales with avatar size + fontSize: size * 0.45, fontWeight: 600, color: textColor, ), ), ); } - - // Use fixed flat color palette and pick based on hash - Color _getFlatColorFromName(String name) { - final colors = [ - Color(0xFFE57373), // Red - Color(0xFFF06292), // Pink - Color(0xFFBA68C8), // Purple - Color(0xFF9575CD), // Deep Purple - Color(0xFF7986CB), // Indigo - Color(0xFF64B5F6), // Blue - Color(0xFF4FC3F7), // Light Blue - Color(0xFF4DD0E1), // Cyan - Color(0xFF4DB6AC), // Teal - Color(0xFF81C784), // Green - Color(0xFFAED581), // Light Green - Color(0xFFDCE775), // Lime - Color(0xFFFFD54F), // Amber - Color(0xFFFFB74D), // Orange - Color(0xFFA1887F), // Brown - Color(0xFF90A4AE), // Blue Grey - ]; - - int index = name.hashCode.abs() % colors.length; - return colors[index]; - } +} + +// Use fixed flat color palette and pick based on hash +Color _getFlatColorFromName(String name) { + final colors = [ + Color(0xFFE57373), // Red + Color(0xFFF06292), // Pink + Color(0xFFBA68C8), // Purple + Color(0xFF9575CD), // Deep Purple + Color(0xFF7986CB), // Indigo + Color(0xFF64B5F6), // Blue + Color(0xFF4FC3F7), // Light Blue + Color(0xFF4DD0E1), // Cyan + Color(0xFF4DB6AC), // Teal + Color(0xFF81C784), // Green + Color(0xFFAED581), // Light Green + Color(0xFFDCE775), // Lime + Color(0xFFFFD54F), // Amber + Color(0xFFFFB74D), // Orange + Color(0xFFA1887F), // Brown + Color(0xFF90A4AE), // Blue Grey + ]; + + int index = name.hashCode.abs() % colors.length; + return colors[index]; } diff --git a/lib/helpers/widgets/custom_app_bar.dart b/lib/helpers/widgets/custom_app_bar.dart index fdd6d5c..0b39ad0 100644 --- a/lib/helpers/widgets/custom_app_bar.dart +++ b/lib/helpers/widgets/custom_app_bar.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; @@ -17,67 +18,67 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return PreferredSize( preferredSize: const Size.fromHeight(72), - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.15), - blurRadius: 0.5, - offset: const Offset(0, 0.5), - ) - ], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: onBackPressed ?? Get.back, - splashRadius: 24, + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new, + color: Colors.black, + size: 20, ), - const SizedBox(width: 8), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge( - title, - fontWeight: 700, - color: Colors.black, - ), - const SizedBox(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), - const SizedBox(width: 4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), + onPressed: onBackPressed ?? () => Get.back(), + ), + MySpacing.width(5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // TITLE + MyText.titleLarge( + title, + fontWeight: 700, + color: Colors.black, + ), + + MySpacing.height(2), + + // PROJECT NAME ROW (copied exactly) + 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], ), - ], - ); - }, - ), - ], - ), + ), + ], + ); + }, + ), + ], ), - ], - ), + ), + ], ), ), ), diff --git a/lib/model/service_project/service_project_job_detail_model.dart b/lib/model/service_project/service_project_job_detail_model.dart new file mode 100644 index 0000000..a74f309 --- /dev/null +++ b/lib/model/service_project/service_project_job_detail_model.dart @@ -0,0 +1,244 @@ +class JobDetailsResponse { + final bool success; + final String message; + final JobData? data; + final dynamic errors; + final int statusCode; + final String timestamp; + + JobDetailsResponse({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory JobDetailsResponse.fromJson(Map json) { + return JobDetailsResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: json['data'] != null ? JobData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: json['timestamp'] as String, + ); + } +} + +class JobData { + 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 User createdBy; + final List tags; + final List updateLogs; + + JobData({ + required this.id, + required this.title, + required this.description, + required this.project, + required this.assignees, + required this.status, + required this.startDate, + required this.dueDate, + required this.isActive, + required this.createdAt, + required this.createdBy, + required this.tags, + required this.updateLogs, + }); + + factory JobData.fromJson(Map json) { + return JobData( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + project: Project.fromJson(json['project']), + assignees: (json['assignees'] as List) + .map((e) => Assignee.fromJson(e)) + .toList(), + status: Status.fromJson(json['status']), + startDate: json['startDate'] as String, + dueDate: json['dueDate'] as String, + isActive: json['isActive'] as bool, + createdAt: json['createdAt'] as String, + createdBy: User.fromJson(json['createdBy']), + tags: (json['tags'] as List).map((e) => Tag.fromJson(e)).toList(), + updateLogs: (json['updateLogs'] as List) + .map((e) => UpdateLog.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'] as String, + name: json['name'] as String, + shortName: json['shortName'] as String, + assignedDate: json['assignedDate'] as String, + contactName: json['contactName'] as String, + contactPhone: json['contactPhone'] as String, + contactEmail: json['contactEmail'] as String, + ); + } +} + +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'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +class Status { + final String id; + final String name; + final String displayName; + final int level; + + Status({ + required this.id, + required this.name, + required this.displayName, + required this.level, + }); + + factory Status.fromJson(Map json) { + return Status( + id: json['id'] as String, + name: json['name'] as String, + displayName: json['displayName'] as String, + level: json['level'] as int, + ); + } +} + +class User { + final String id; + final String firstName; + final String lastName; + final String email; + final String photo; + final String jobRoleId; + final String jobRoleName; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + photo: json['photo'] as String, + jobRoleId: json['jobRoleId'] as String, + jobRoleName: json['jobRoleName'] as String, + ); + } +} + +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'] as String, + name: json['name'] as String, + ); + } +} + +class UpdateLog { + final String id; + final Status? status; + final Status nextStatus; + final String comment; + final User updatedBy; + + UpdateLog({ + required this.id, + this.status, + required this.nextStatus, + required this.comment, + required this.updatedBy, + }); + + factory UpdateLog.fromJson(Map json) { + return UpdateLog( + id: json['id'] as String, + status: json['status'] != null ? Status.fromJson(json['status']) : null, + nextStatus: Status.fromJson(json['nextStatus']), + comment: json['comment'] as String, + updatedBy: User.fromJson(json['updatedBy']), + ); + } +} diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index ad39ef7..6e1dc36 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; -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'; @@ -9,6 +8,9 @@ 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'; +import 'package:marco/view/service_project/service_project_job_detail_screen.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -345,115 +347,118 @@ class _ServiceProjectDetailsScreenState } 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(), - ), + return InkWell( + onTap: () { + Get.to(() => JobDetailsScreen(jobId: job.id)); + }, + child: 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: [ + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(6), + MyText.bodySmall( + job.description.isNotEmpty + ? job.description + : "No description provided", + color: Colors.grey[700], ), - 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), + // 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(), + ), ), - const Spacer(), + MySpacing.height(8), - // 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), + // Assignees & Status + Row( + children: [ + if (job.assignees != null && job.assignees!.isNotEmpty) + ...job.assignees!.map((assignee) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: Avatar( + firstName: assignee.firstName, + lastName: assignee.lastName, + size: + 24, + imageUrl: assignee.photo.isNotEmpty + ? assignee.photo + : 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), ), - child: Text( - job.status.displayName, - style: TextStyle( - fontSize: 12, + + const Spacer(), + + // Status Chip + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( color: job.status.name.toLowerCase() == 'completed' - ? Colors.green[800] - : Colors.orange[800], - fontWeight: FontWeight.w600, + ? 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, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); @@ -466,64 +471,9 @@ class _ServiceProjectDetailsScreenState Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.toNamed('/dashboard/service-projects'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Service Project Details', - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Project Details", + onBackPressed: () => Get.toNamed('/dashboard/service-projects'), ), body: SafeArea( child: Column( diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart new file mode 100644 index 0000000..09c2990 --- /dev/null +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/service_project/service_project_details_screen_controller.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/utils/date_time_utils.dart'; +import 'package:marco/helpers/utils/launcher_utils.dart'; +import 'package:timeline_tile/timeline_tile.dart'; +import 'package:marco/model/service_project/service_project_job_detail_model.dart'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; + +class JobDetailsScreen extends StatefulWidget { + final String jobId; + + const JobDetailsScreen({super.key, required this.jobId}); + + @override + State createState() => _JobDetailsScreenState(); +} + +class _JobDetailsScreenState extends State with UIMixin { + late final ServiceProjectDetailsController controller; + + @override + void initState() { + super.initState(); + controller = Get.put(ServiceProjectDetailsController()); + controller.fetchJobDetail(widget.jobId); + } + + Widget _buildSectionCard({ + required String title, + required IconData titleIcon, + required List children, + }) { + return Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(titleIcon, size: 20), + MySpacing.width(8), + MyText.bodyLarge( + title, + fontWeight: 700, + fontSize: 16, + ) + ], + ), + MySpacing.height(8), + const Divider(), + ...children + ], + ), + ), + ); + } + + Widget _rowTile(String label, String value, {bool copyable = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: MyText.bodySmall(label, + color: Colors.grey[600], fontWeight: 600), + ), + Expanded( + flex: 5, + child: GestureDetector( + onLongPress: copyable + ? () => LauncherUtils.copyToClipboard(value, typeLabel: label) + : null, + child: MyText.bodyMedium(value, + fontWeight: 600, + color: copyable ? Colors.blue : Colors.black87), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: CustomAppBar( + title: "Service Project Job Details", + onBackPressed: () => Get.back(), + ), + body: Obx(() { + if (controller.isJobDetailLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.jobDetailErrorMessage.value.isNotEmpty) { + return Center( + child: MyText.bodyMedium(controller.jobDetailErrorMessage.value), + ); + } + + final job = controller.jobDetail.value?.data; + if (job == null) { + return Center(child: MyText.bodyMedium("No details available")); + } + + return SingleChildScrollView( + padding: MySpacing.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ====== HEADER CARD ======= + Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.task_outlined, size: 35), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium(job.title, fontWeight: 700), + MySpacing.height(5), + MyText.bodySmall(job.project.name, + color: Colors.grey[700]), + ], + ), + ) + ], + ), + ), + ), + + MySpacing.height(20), + + // ====== Job Information ======= + _buildSectionCard( + title: "Job Information", + titleIcon: Icons.info_outline, + children: [ + _rowTile("Description", job.description), + _rowTile( + "Start Date", + DateTimeUtils.convertUtcToLocal(job.startDate, + format: "dd MMM yyyy"), + ), + _rowTile( + "Due Date", + DateTimeUtils.convertUtcToLocal(job.dueDate, + format: "dd MMM yyyy"), + ), + _rowTile("Status", job.status.displayName), + ], + ), + + MySpacing.height(16), + + // ====== Assignees ======= + _buildSectionCard( + title: "Assigned To", + titleIcon: Icons.people_outline, + children: job.assignees.isNotEmpty + ? job.assignees.map((a) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Avatar( + firstName: a.firstName, + lastName: a.lastName, + size: + 32, + backgroundColor: + a.photo.isEmpty ? null : Colors.transparent, + textColor: Colors.white, + ), + MySpacing.width(10), + MyText.bodyMedium("${a.firstName} ${a.lastName}"), + ], + ), + ); + }).toList() + : [MyText.bodySmall("No assignees", color: Colors.grey)], + ), + + MySpacing.height(16), + + // ====== Tags ======= + if (job.tags.isNotEmpty) + _buildSectionCard( + title: "Tags", + titleIcon: Icons.label_outline, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: job.tags.map((tag) { + return Chip( + label: Text(tag.name), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ); + }).toList(), + ) + ], + ), + + MySpacing.height(16), + + // ====== Update Logs (Timeline UI) ======= + if (job.updateLogs.isNotEmpty) + _buildSectionCard( + title: "Update Logs", + titleIcon: Icons.history, + children: [ + JobTimeline(logs: job.updateLogs), + ], + ), + + MySpacing.height(40), + ], + ), + ); + }), + ); + } +} + +class JobTimeline extends StatelessWidget { + final List logs; + + const JobTimeline({super.key, required this.logs}); + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) { + return MyText.bodyMedium('No timeline available', color: Colors.grey); + } + + // Show latest updates at the top + final reversedLogs = logs.reversed.toList(); + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: reversedLogs.length, + itemBuilder: (_, index) { + final log = reversedLogs[index]; + + final statusName = log.status?.displayName ?? "Created"; + final nextStatusName = log.nextStatus.displayName; + final comment = log.comment; + + final updatedBy = + "${log.updatedBy.firstName} ${log.updatedBy.lastName}"; + + final initials = + "${log.updatedBy.firstName.isNotEmpty ? log.updatedBy.firstName[0] : ''}" + "${log.updatedBy.lastName.isNotEmpty ? log.updatedBy.lastName[0] : ''}"; + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: index == 0, + isLast: index == reversedLogs.length - 1, + indicatorStyle: IndicatorStyle( + width: 16, + height: 16, + indicator: Container( + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + ), + beforeLineStyle: LineStyle( + color: Colors.grey.shade300, + thickness: 2, + ), + endChild: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // STATUS CHANGE ROW + MyText.bodyMedium( + "$statusName → $nextStatusName", + fontWeight: 600, + ), + const SizedBox(height: 8), + + // COMMENT + if (comment.isNotEmpty) MyText.bodyMedium(comment), + + const SizedBox(height: 10), + + // Updated by + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall(initials, fontWeight: 600), + ), + const SizedBox(width: 6), + Expanded( + child: MyText.bodySmall(updatedBy), + ), + ], + ), + + const SizedBox(height: 10), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index 8831369..50bdee7 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -8,7 +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'; +import 'package:marco/helpers/widgets/custom_app_bar.dart'; class ServiceProjectScreen extends StatefulWidget { const ServiceProjectScreen({super.key}); @@ -194,64 +194,9 @@ class _ServiceProjectScreenState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.toNamed('/dashboard'), - ), - MySpacing.width(8), - 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], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), + appBar: CustomAppBar( + title: "Service Projects", + onBackPressed: () => Get.toNamed('/dashboard'), ), body: Column( children: [ From ffd18a9e407462f580989ed5ae2009bed848ff61 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:15:17 +0530 Subject: [PATCH 31/35] added edit job api --- lib/helpers/services/api_endpoints.dart | 7 +- lib/helpers/services/api_service.dart | 98 ++++++++++++++++++------- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 04823ee..fae25cf 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,8 +2,7 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.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://mapi.marcoaiot.com/api"; - + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = @@ -140,6 +139,8 @@ class ApiEndpoints { 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 getServiceProjectJobDetail = + "/serviceproject/job/details"; + static const String editServiceProjectJob = "/serviceproject/job/edit"; static const String createServiceProjectJob = "/serviceproject/job/create"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index cb74f50..e148861 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -307,35 +307,83 @@ class ApiService { } // Service Project Module APIs + /// Edit a Service Project Job + static Future editServiceProjectJobApi({ + required String jobId, + required List> + operations, + }) async { + final endpoint = "${ApiEndpoints.editServiceProjectJob}/$jobId"; + logSafe("Editing Service Project Job: $jobId with operations: $operations"); -/// Get details for a single Service Project Job -static Future getServiceProjectJobDetailApi(String jobId) async { - final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; - logSafe("Fetching Job Detail for Job ID: $jobId"); - - try { - final response = await _getRequest(endpoint); - if (response == null) { - logSafe("Service Project Job Detail request failed: null response", level: LogLevel.error); - return null; + try { + // PATCH request is usually similar to PUT, but with http.patch + String? token = await _getToken(); + if (token == null) return false; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + final headers = _headers(token); + + final response = await http + .patch(uri, headers: headers, body: jsonEncode(operations)) + .timeout(extendedTimeout); + + logSafe( + "Edit Service Project Job response status: ${response.statusCode}"); + logSafe("Edit Service Project Job response body: ${response.body}"); + + final json = jsonDecode(response.body); + + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Service Project Job edited successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to edit Service Project Job: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during editServiceProjectJobApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; } - - final jsonResponse = _parseResponseForAllData( - response, - label: "Service Project Job Detail", - ); - - if (jsonResponse != null) { - return JobDetailsResponse.fromJson(jsonResponse); - } - } catch (e, stack) { - logSafe("Exception during getServiceProjectJobDetailApi: $e", level: LogLevel.error); - logSafe("StackTrace: $stack", level: LogLevel.debug); } - - return null; -} + + /// Get details for a single Service Project Job + static Future getServiceProjectJobDetailApi( + String jobId) async { + final endpoint = "${ApiEndpoints.getServiceProjectJobDetail}/$jobId"; + logSafe("Fetching Job Detail for Job ID: $jobId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Service Project Job Detail request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Service Project Job Detail", + ); + + if (jsonResponse != null) { + return JobDetailsResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getServiceProjectJobDetailApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } /// Create a new Service Project Job static Future createServiceProjectJobApi({ From e1952d505bd6e91b1b5875f87e5f4a48c6986929 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:22:31 +0530 Subject: [PATCH 32/35] modified api service --- lib/helpers/services/api_service.dart | 28 +-------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index e148861..4340118 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -310,8 +310,7 @@ class ApiService { /// Edit a Service Project Job static Future editServiceProjectJobApi({ required String jobId, - required List> - operations, + required List> operations, }) async { final endpoint = "${ApiEndpoints.editServiceProjectJob}/$jobId"; @@ -505,35 +504,20 @@ class ApiService { } /// 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"); 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); try { final response = await _getRequest(endpoint); - if (response == null) { - logSafe("Service Project Detail request failed: null response", - level: LogLevel.error); - return null; - } if (response == null) { logSafe("Service Project Detail request failed: null response", level: LogLevel.error); return null; } - final jsonResponse = _parseResponseForAllData( - response, - label: "Service Project Detail", - ); final jsonResponse = _parseResponseForAllData( response, label: "Service Project Detail", @@ -547,19 +531,9 @@ class ApiService { level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } - 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; } - return null; - } /// Get Service Project List static Future getServiceProjectsListApi({ From 7ee65115be2d36c61d0a625617716a004e7c69ba Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:08:50 +0530 Subject: [PATCH 33/35] added service project job details screen --- lib/helpers/services/api_endpoints.dart | 3 ++- lib/helpers/services/api_service.dart | 28 +------------------------ 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index fae25cf..900d0c2 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,7 +2,8 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.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://mapi.marcoaiot.com/api"; + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; + static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index e148861..4340118 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -310,8 +310,7 @@ class ApiService { /// Edit a Service Project Job static Future editServiceProjectJobApi({ required String jobId, - required List> - operations, + required List> operations, }) async { final endpoint = "${ApiEndpoints.editServiceProjectJob}/$jobId"; @@ -505,35 +504,20 @@ class ApiService { } /// 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"); 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); try { final response = await _getRequest(endpoint); - if (response == null) { - logSafe("Service Project Detail request failed: null response", - level: LogLevel.error); - return null; - } if (response == null) { logSafe("Service Project Detail request failed: null response", level: LogLevel.error); return null; } - final jsonResponse = _parseResponseForAllData( - response, - label: "Service Project Detail", - ); final jsonResponse = _parseResponseForAllData( response, label: "Service Project Detail", @@ -547,19 +531,9 @@ class ApiService { level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } - 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; } - return null; - } /// Get Service Project List static Future getServiceProjectsListApi({ From 7e3d2ac2b9538c07178b71cfa6f08bbd30c1302c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 11:15:17 +0530 Subject: [PATCH 34/35] added edit job api --- lib/helpers/services/api_endpoints.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 900d0c2..fae25cf 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,8 +2,7 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.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://mapi.marcoaiot.com/api"; - + // static const String baseUrl = "https://mapi.marcoaiot.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; static const String getMasterExpensesCategories = From 10e9f4a315a386ebff2ed3d0eeaece5d7594ba35 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 14:49:44 +0530 Subject: [PATCH 35/35] added edit job fucntioanllity --- ...ice_project_details_screen_controller.dart | 30 +- lib/helpers/services/api_service.dart | 2 +- .../service_project_job_detail_screen.dart | 567 ++++++++++++------ 3 files changed, 405 insertions(+), 194 deletions(-) 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 6f70896..6a3f1d6 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -5,16 +5,10 @@ import 'package:marco/model/service_project/job_list_model.dart'; import 'package:marco/model/service_project/service_project_job_detail_model.dart'; class ServiceProjectDetailsController extends GetxController { - // Selected project id - var projectId = ''.obs; - - // Project details + // -------------------- Observables -------------------- + var projectId = ''.obs; var projectDetail = Rxn(); - - // Job list var jobList = [].obs; - - // Job detail for a selected job var jobDetail = Rxn(); // Loading states @@ -32,14 +26,14 @@ class ServiceProjectDetailsController extends GetxController { final int pageSize = 20; var hasMoreJobs = true.obs; + // -------------------- Lifecycle -------------------- @override void onInit() { super.onInit(); - // Fetch job list initially even if projectId is empty - fetchProjectJobs(initialLoad: true); + fetchProjectJobs(initialLoad: true); // fetch job list initially } - /// Set project id and fetch its details + jobs + // -------------------- Project -------------------- void setProjectId(String id) { projectId.value = id; fetchProjectDetail(); @@ -48,7 +42,6 @@ class ServiceProjectDetailsController extends GetxController { fetchProjectJobs(initialLoad: true); } - /// Fetch project detail Future fetchProjectDetail() async { if (projectId.value.isEmpty) { errorMessage.value = "Invalid project ID"; @@ -73,7 +66,7 @@ class ServiceProjectDetailsController extends GetxController { } } - /// Fetch project job list + // -------------------- Job List -------------------- Future fetchProjectJobs({bool initialLoad = false}) async { if (projectId.value.isEmpty && !initialLoad) { jobErrorMessage.value = "Invalid project ID"; @@ -87,7 +80,7 @@ class ServiceProjectDetailsController extends GetxController { try { final result = await ApiService.getServiceProjectJobListApi( - projectId: projectId.value, + projectId: projectId.value, pageNumber: pageNumber, pageSize: pageSize, isActive: true, @@ -112,12 +105,9 @@ class ServiceProjectDetailsController extends GetxController { } } - /// Fetch more jobs for pagination - Future fetchMoreJobs() async { - await fetchProjectJobs(); - } + Future fetchMoreJobs() async => fetchProjectJobs(); - /// Manual refresh + // -------------------- Manual Refresh -------------------- Future refresh() async { pageNumber = 1; hasMoreJobs.value = true; @@ -127,7 +117,7 @@ class ServiceProjectDetailsController extends GetxController { ]); } - /// Fetch job details by job ID + // -------------------- Job Detail -------------------- Future fetchJobDetail(String jobId) async { if (jobId.isEmpty) { jobDetailErrorMessage.value = "Invalid job ID"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 4340118..c10a6e4 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -319,7 +319,7 @@ class ApiService { try { // PATCH request is usually similar to PUT, but with http.patch String? token = await _getToken(); - if (token == null) return false; + if (token == null) return false; final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); diff --git a/lib/view/service_project/service_project_job_detail_screen.dart b/lib/view/service_project/service_project_job_detail_screen.dart index 09c2990..7f06261 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -5,15 +5,16 @@ 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/utils/date_time_utils.dart'; -import 'package:marco/helpers/utils/launcher_utils.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:marco/model/service_project/service_project_job_detail_model.dart'; import 'package:marco/helpers/widgets/custom_app_bar.dart'; -import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; +import 'package:marco/model/employees/multiple_select_bottomsheet.dart'; +import 'package:marco/model/employees/employee_model.dart'; class JobDetailsScreen extends StatefulWidget { final String jobId; - const JobDetailsScreen({super.key, required this.jobId}); @override @@ -23,11 +24,167 @@ class JobDetailsScreen extends StatefulWidget { class _JobDetailsScreenState extends State with UIMixin { late final ServiceProjectDetailsController controller; + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _startDateController = TextEditingController(); + final _dueDateController = TextEditingController(); + + RxList _selectedAssignees = [].obs; + RxList _selectedTags = [].obs; + final _tagTextController = TextEditingController(); + + RxBool isEditing = false.obs; // track edit/view mode + @override void initState() { super.initState(); controller = Get.put(ServiceProjectDetailsController()); - controller.fetchJobDetail(widget.jobId); + controller.fetchJobDetail(widget.jobId).then((_) { + final job = controller.jobDetail.value?.data; + if (job != null) { + _titleController.text = job.title; + _descriptionController.text = job.description; + _startDateController.text = DateTimeUtils.convertUtcToLocal( + job.startDate, + format: "yyyy-MM-dd"); + _dueDateController.text = + DateTimeUtils.convertUtcToLocal(job.dueDate, format: "yyyy-MM-dd"); + _selectedAssignees.assignAll(job.assignees); + _selectedTags.assignAll(job.tags); + } + }); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _startDateController.dispose(); + _dueDateController.dispose(); + _tagTextController.dispose(); + super.dispose(); + } + + Future _editJob() async { + final job = controller.jobDetail.value?.data; + if (job == null) return; + + final operations = >[]; + + // Title + if (_titleController.text.trim() != job.title) { + operations.add({ + "op": "replace", + "path": "/title", + "value": _titleController.text.trim(), + }); + } + + // Description + if (_descriptionController.text.trim() != job.description) { + operations.add({ + "op": "replace", + "path": "/description", + "value": _descriptionController.text.trim(), + }); + } + + // Dates + final startDate = DateTime.tryParse(_startDateController.text); + final dueDate = DateTime.tryParse(_dueDateController.text); + + if (startDate != null && startDate.toUtc() != job.startDate) { + operations.add({ + "op": "replace", + "path": "/startDate", + "value": startDate.toUtc().toIso8601String(), + }); + } + + if (dueDate != null && dueDate.toUtc() != job.dueDate) { + operations.add({ + "op": "replace", + "path": "/dueDate", + "value": dueDate.toUtc().toIso8601String(), + }); + } + + // Assignees + final originalAssignees = job.assignees ?? []; + final assigneesPayload = originalAssignees.map((a) { + final isSelected = _selectedAssignees.any((s) => s.id == a.id); + return { + "employeeId": a.id, + "isActive": isSelected, + }; + }).toList(); + + _selectedAssignees.forEach((s) { + if (!originalAssignees.any((a) => a.id == s.id)) { + assigneesPayload.add({ + "employeeId": s.id, + "isActive": true, + }); + } + }); + + operations.add({ + "op": "replace", + "path": "/assignees", + "value": assigneesPayload, + }); + + // Tags + final originalTags = job.tags ?? []; + + final replaceTagsPayload = originalTags.map((t) { + final isSelected = _selectedTags.any((s) => s.id == t.id); + return { + "id": t.id, + "name": t.name, + "isActive": isSelected, + }; + }).toList(); + + final addTagsPayload = _selectedTags + .where((t) => t.id == "0") + .map((t) => { + "name": t.name, + "isActive": true, + }) + .toList(); + + if (replaceTagsPayload.isNotEmpty) { + operations.add({ + "op": "replace", + "path": "/tags", + "value": replaceTagsPayload, + }); + } + + if (addTagsPayload.isNotEmpty) { + operations.add({ + "op": "add", + "path": "/tags", + "value": addTagsPayload, + }); + } + + if (operations.isEmpty) { + Get.snackbar("Info", "No changes detected to save."); + return; + } + + final success = await ApiService.editServiceProjectJobApi( + jobId: job.id, operations: operations); + + if (success) { + Get.snackbar("Success", "Job updated successfully"); + await controller.fetchJobDetail(widget.jobId); + isEditing.value = false; // switch back to view mode + } else { + Get.snackbar("Error", "Failed to update job. Check inputs or try again."); + } } Widget _buildSectionCard({ @@ -36,9 +193,10 @@ class _JobDetailsScreenState extends State with UIMixin { required List children, }) { return Card( - elevation: 2, + elevation: 3, shadowColor: Colors.black12, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + margin: const EdgeInsets.symmetric(vertical: 8), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -46,49 +204,220 @@ class _JobDetailsScreenState extends State with UIMixin { children: [ Row( children: [ - Icon(titleIcon, size: 20), + Icon(titleIcon, size: 20, color: Colors.blueAccent), MySpacing.width(8), - MyText.bodyLarge( - title, - fontWeight: 700, - fontSize: 16, - ) + MyText.bodyLarge(title, fontWeight: 700, fontSize: 16), ], ), MySpacing.height(8), const Divider(), - ...children + ...children, ], ), ), ); } - Widget _rowTile(String label, String value, {bool copyable = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: MyText.bodySmall(label, + Widget _editableRow(String label, TextEditingController controller) { + return Obx(() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, + color: Colors.grey[600], fontWeight: 600), + const SizedBox(height: 6), + isEditing.value + ? TextField( + controller: controller, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5)), + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + ), + ) + : Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(5), + ), + child: Text(controller.text, style: const TextStyle(fontSize: 14)), + ), + ], + ), + )); + } + + Widget _assigneeSelector() { + return Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Assigned To", color: Colors.grey[600], fontWeight: 600), - ), - Expanded( - flex: 5, - child: GestureDetector( - onLongPress: copyable - ? () => LauncherUtils.copyToClipboard(value, typeLabel: label) + const SizedBox(height: 8), + GestureDetector( + onTap: isEditing.value + ? () async { + final initiallySelected = + _selectedAssignees.map((a) { + return EmployeeModel( + id: a.id, + employeeId: a.id, + firstName: a.firstName, + lastName: a.lastName, + name: "${a.firstName} ${a.lastName}", + designation: a.jobRoleName ?? '', + jobRole: a.jobRoleName ?? '', + jobRoleID: a.jobRoleId ?? '', + email: a.email ?? '', + phoneNumber: '', + activity: 0, + action: 0, + ); + }).toList(); + + final selected = + await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => EmployeeSelectionBottomSheet( + multipleSelection: true, + initiallySelected: initiallySelected, + ), + ); + + if (selected != null) { + final newAssignees = selected.map((e) { + return Assignee( + id: e.id, + firstName: e.firstName, + lastName: e.lastName, + email: e.email ?? '', + photo: '', + jobRoleId: e.jobRoleID ?? '', + jobRoleName: e.jobRole ?? '', + ); + }).toList(); + + _selectedAssignees.assignAll(newAssignees); + } + } : null, - child: MyText.bodyMedium(value, - fontWeight: 600, - color: copyable ? Colors.blue : Colors.black87), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + _selectedAssignees.isEmpty + ? "No Assignees" + : _selectedAssignees + .map((e) => "${e.firstName} ${e.lastName}") + .join(", "), + style: const TextStyle(fontSize: 14), + ), + ), ), - ), - ], - ), - ); + ], + )); + } + + Widget _dateRangePicker() { + return Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Select Date Range", + color: Colors.grey[600], fontWeight: 600), + const SizedBox(height: 8), + isEditing.value + ? DateRangePickerWidget( + startDate: Rx( + DateTime.tryParse(_startDateController.text) ?? + DateTime.now()), + endDate: Rx( + DateTime.tryParse(_dueDateController.text) ?? + DateTime.now().add(const Duration(days: 1))), + startLabel: "Start Date", + endLabel: "Due Date", + onDateRangeSelected: (start, end) { + if (start != null && end != null) { + _startDateController.text = + start.toIso8601String().split("T").first; + _dueDateController.text = + end.toIso8601String().split("T").first; + } + }, + ) + : Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + "${_startDateController.text} → ${_dueDateController.text}"), + ), + ], + )); + } + + Widget _tagEditor() { + return Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Tags", + color: Colors.grey[600], fontWeight: 600), + const SizedBox(height: 8), + if (isEditing.value) + TextField( + controller: _tagTextController, + onSubmitted: (v) { + final value = v.trim(); + if (value.isNotEmpty && + !_selectedTags.any((t) => t.name == value)) { + setState(() { + _selectedTags.add(Tag(id: "0", name: value)); + }); + } + _tagTextController.clear(); + }, + decoration: InputDecoration( + hintText: "Type and press enter to add tags", + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5)), + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + children: _selectedTags + .map((t) => Chip( + label: Text(t.name), + onDeleted: isEditing.value + ? () { + setState(() { + _selectedTags.remove(t); + }); + } + : null, + )) + .toList(), + ), + ], + )); } @override @@ -99,6 +428,11 @@ class _JobDetailsScreenState extends State with UIMixin { title: "Service Project Job Details", onBackPressed: () => Get.back(), ), + floatingActionButton: Obx(() => FloatingActionButton.extended( + onPressed: isEditing.value ? _editJob : () => isEditing.value = true, + label: Text(isEditing.value ? "Save" : "Edit"), + icon: Icon(isEditing.value ? Icons.save : Icons.edit), + )), body: Obx(() { if (controller.isJobDetailLoading.value) { return const Center(child: CircularProgressIndicator()); @@ -106,8 +440,7 @@ class _JobDetailsScreenState extends State with UIMixin { if (controller.jobDetailErrorMessage.value.isNotEmpty) { return Center( - child: MyText.bodyMedium(controller.jobDetailErrorMessage.value), - ); + child: MyText.bodyMedium(controller.jobDetailErrorMessage.value)); } final job = controller.jobDetail.value?.data; @@ -120,122 +453,31 @@ class _JobDetailsScreenState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ====== HEADER CARD ======= - Card( - elevation: 2, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.task_outlined, size: 35), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium(job.title, fontWeight: 700), - MySpacing.height(5), - MyText.bodySmall(job.project.name, - color: Colors.grey[700]), - ], - ), - ) - ], - ), - ), - ), - - MySpacing.height(20), - - // ====== Job Information ======= _buildSectionCard( - title: "Job Information", - titleIcon: Icons.info_outline, + title: "Job Info", + titleIcon: Icons.task_outlined, children: [ - _rowTile("Description", job.description), - _rowTile( - "Start Date", - DateTimeUtils.convertUtcToLocal(job.startDate, - format: "dd MMM yyyy"), - ), - _rowTile( - "Due Date", - DateTimeUtils.convertUtcToLocal(job.dueDate, - format: "dd MMM yyyy"), - ), - _rowTile("Status", job.status.displayName), + _editableRow("Title", _titleController), + _editableRow("Description", _descriptionController), + _dateRangePicker(), ], ), - + MySpacing.height(12), + _assigneeSelector(), MySpacing.height(16), - - // ====== Assignees ======= _buildSectionCard( - title: "Assigned To", - titleIcon: Icons.people_outline, - children: job.assignees.isNotEmpty - ? job.assignees.map((a) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Avatar( - firstName: a.firstName, - lastName: a.lastName, - size: - 32, - backgroundColor: - a.photo.isEmpty ? null : Colors.transparent, - textColor: Colors.white, - ), - MySpacing.width(10), - MyText.bodyMedium("${a.firstName} ${a.lastName}"), - ], - ), - ); - }).toList() - : [MyText.bodySmall("No assignees", color: Colors.grey)], + title: "Tags", + titleIcon: Icons.label_outline, + children: [_tagEditor()], ), - MySpacing.height(16), - - // ====== Tags ======= - if (job.tags.isNotEmpty) - _buildSectionCard( - title: "Tags", - titleIcon: Icons.label_outline, - children: [ - Wrap( - spacing: 6, - runSpacing: 6, - children: job.tags.map((tag) { - return Chip( - label: Text(tag.name), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - ); - }).toList(), - ) - ], - ), - - MySpacing.height(16), - - // ====== Update Logs (Timeline UI) ======= if (job.updateLogs.isNotEmpty) _buildSectionCard( title: "Update Logs", titleIcon: Icons.history, - children: [ - JobTimeline(logs: job.updateLogs), - ], + children: [JobTimeline(logs: job.updateLogs)], ), - - MySpacing.height(40), + MySpacing.height(80), ], ), ); @@ -246,16 +488,13 @@ class _JobDetailsScreenState extends State with UIMixin { class JobTimeline extends StatelessWidget { final List logs; - const JobTimeline({super.key, required this.logs}); @override Widget build(BuildContext context) { - if (logs.isEmpty) { + if (logs.isEmpty) return MyText.bodyMedium('No timeline available', color: Colors.grey); - } - // Show latest updates at the top final reversedLogs = logs.reversed.toList(); return ListView.builder( @@ -264,14 +503,11 @@ class JobTimeline extends StatelessWidget { itemCount: reversedLogs.length, itemBuilder: (_, index) { final log = reversedLogs[index]; - final statusName = log.status?.displayName ?? "Created"; final nextStatusName = log.nextStatus.displayName; final comment = log.comment; - final updatedBy = "${log.updatedBy.firstName} ${log.updatedBy.lastName}"; - final initials = "${log.updatedBy.firstName.isNotEmpty ? log.updatedBy.firstName[0] : ''}" "${log.updatedBy.lastName.isNotEmpty ? log.updatedBy.lastName[0] : ''}"; @@ -285,51 +521,36 @@ class JobTimeline extends StatelessWidget { height: 16, indicator: Container( decoration: const BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - ), + color: Colors.blue, shape: BoxShape.circle), ), ), - beforeLineStyle: LineStyle( - color: Colors.grey.shade300, - thickness: 2, - ), + beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // STATUS CHANGE ROW - MyText.bodyMedium( - "$statusName → $nextStatusName", - fontWeight: 600, - ), - const SizedBox(height: 8), - - // COMMENT - if (comment.isNotEmpty) MyText.bodyMedium(comment), - + MyText.bodyMedium("$statusName → $nextStatusName", + fontWeight: 600), + if (comment.isNotEmpty) ...[ + const SizedBox(height: 8), + MyText.bodyMedium(comment), + ], const SizedBox(height: 10), - - // Updated by Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(4), - ), + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4)), child: MyText.bodySmall(initials, fontWeight: 600), ), const SizedBox(width: 6), - Expanded( - child: MyText.bodySmall(updatedBy), - ), + Expanded(child: MyText.bodySmall(updatedBy)), ], ), - const SizedBox(height: 10), ], ),