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 41f0954..add77c9 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -17,6 +17,7 @@ class ServiceProjectDetailsController extends GetxController { var projectDetail = Rxn(); var jobList = [].obs; var jobDetail = Rxn(); + var showArchivedJobs = false.obs; // true = archived, false = active // Loading states var isLoading = false.obs; @@ -39,21 +40,47 @@ class ServiceProjectDetailsController extends GetxController { var teamList = [].obs; var isTeamLoading = false.obs; var teamErrorMessage = ''.obs; + var filteredJobList = [].obs; // -------------------- Lifecycle -------------------- @override void onInit() { super.onInit(); - fetchProjectJobs(); // always load jobs even without projectId + fetchProjectJobs(); + filteredJobList.value = jobList; } // -------------------- Project -------------------- void setProjectId(String id) { + if (projectId.value == id) return; projectId.value = id; - fetchProjectDetail(); + + // Reset pagination and list pageNumber = 1; hasMoreJobs.value = true; - fetchProjectJobs(); // no initialLoad + jobList.clear(); + filteredJobList.clear(); + + // Fetch project detail + fetchProjectDetail(); + + // Always fetch jobs for this project + fetchProjectJobs(refresh: true); + } + + void updateJobSearch(String searchText) { + if (searchText.isEmpty) { + filteredJobList.value = jobList; + } else { + filteredJobList.value = jobList.where((job) { + final lowerSearch = searchText.toLowerCase(); + return job.title.toLowerCase().contains(lowerSearch) || + (job.description.toLowerCase().contains(lowerSearch)) || + (job.tags?.any( + (tag) => tag.name.toLowerCase().contains(lowerSearch)) ?? + false); + }).toList(); + } } Future fetchProjectTeams() async { @@ -135,33 +162,39 @@ class ServiceProjectDetailsController extends GetxController { } // -------------------- Job List (modified to always load) -------------------- - Future fetchProjectJobs() async { - if (!hasMoreJobs.value) return; + Future fetchProjectJobs({bool refresh = false}) async { + if (projectId.value.isEmpty) return; + + if (refresh) pageNumber = 1; + if (!hasMoreJobs.value && !refresh) return; isJobLoading.value = true; jobErrorMessage.value = ''; try { final result = await ApiService.getServiceProjectJobListApi( - projectId: projectId.value, // allows empty projectId + projectId: projectId.value, pageNumber: pageNumber, pageSize: pageSize, isActive: true, + isArchive: showArchivedJobs.value, ); if (result != null && result.data != null) { final newJobs = result.data?.data ?? []; - if (pageNumber == 1) { + if (refresh || pageNumber == 1) { jobList.value = newJobs; } else { jobList.addAll(newJobs); } + filteredJobList.value = jobList; + hasMoreJobs.value = newJobs.length == pageSize; if (hasMoreJobs.value) pageNumber++; } else { - jobErrorMessage.value = result?.message ?? "Failed to fetch job list"; + jobErrorMessage.value = result?.message ?? "Failed to fetch jobs"; } } catch (e) { jobErrorMessage.value = "Error fetching jobs: $e"; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f68ad6f..743c8e1 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,9 +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 baseUrl = "https://api.onfieldwork.com/api"; + // static const String baseUrl = "https://api.onfieldwork.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 880c6aa..73622be 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -652,15 +652,18 @@ class ApiService { } } - /// Get Service Project Job List + /// Get Service Project Job List (Active or Archived) static Future getServiceProjectJobListApi({ required String projectId, int pageNumber = 1, int pageSize = 20, bool isActive = true, + bool isArchive = false, // new parameter to fetch archived jobs }) async { const endpoint = ApiEndpoints.getServiceProjectJobList; - logSafe("Fetching Job List for Service Project: $projectId"); + logSafe( + "Fetching Job List for Service Project: $projectId | isActive: $isActive | isArchive: $isArchive", + ); try { final queryParams = { @@ -668,27 +671,35 @@ class ApiService { 'pageNumber': pageNumber.toString(), 'pageSize': pageSize.toString(), 'isActive': isActive.toString(), + if (isArchive) + 'isArchive': 'true', }; final response = await _getRequest(endpoint, queryParams: queryParams); if (response == null) { - logSafe("Service Project Job List request failed: null response", - level: LogLevel.error); + logSafe( + "Service Project Job List request failed: null response", + level: LogLevel.error, + ); return null; } final jsonResponse = _parseResponseForAllData( response, - label: "Service Project Job List", + label: isArchive + ? "Archived Service Project Job List" + : "Active Service Project Job List", ); if (jsonResponse != null) { return JobResponse.fromJson(jsonResponse); } } catch (e, stack) { - logSafe("Exception during getServiceProjectJobListApi: $e", - level: LogLevel.error); + logSafe( + "Exception during getServiceProjectJobListApi: $e", + level: LogLevel.error, + ); logSafe("StackTrace: $stack", level: LogLevel.debug); } diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index a5e9036..b5e3b2c 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -161,6 +161,30 @@ class MenuItems { /// Documents menu static const String documents = "92d2cc39-9e6a-46b2-ae50-84fbf83c95d3"; - /// Service Projects + /// Service Projects static const String serviceProjects = "7faddfe7-994b-4712-91c2-32ba44129d9b"; } + +/// Contains all job status IDs used across the application. +class JobStatus { + /// Level 1 - New + static const String newStatus = "32d76a02-8f44-4aa0-9b66-c3716c45a918"; + + /// Level 2 - Assigned + static const String assigned = "cfa1886d-055f-4ded-84c6-42a2a8a14a66"; + + /// Level 3 - In Progress + static const String inProgress = "5a6873a5-fed7-4745-a52f-8f61bf3bd72d"; + + /// Level 4 - Work Done + static const String workDone = "aab71020-2fb8-44d9-9430-c9a7e9bf33b0"; + + /// Level 5 - Review Done + static const String reviewDone = "ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7"; + + /// Level 6 - Closed + static const String closed = "3ddeefb5-ae3c-4e10-a922-35e0a452bb69"; + + /// Level 7 - On Hold + static const String onHold = "75a0c8b8-9c6a-41af-80bf-b35bab722eb2"; +} diff --git a/lib/helpers/widgets/my_confirmation_dialog.dart b/lib/helpers/widgets/my_confirmation_dialog.dart index 28dfa60..f70b5e7 100644 --- a/lib/helpers/widgets/my_confirmation_dialog.dart +++ b/lib/helpers/widgets/my_confirmation_dialog.dart @@ -6,13 +6,37 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; class ConfirmDialog extends StatelessWidget { final String title; final String message; + + /// Text for confirm button (default: "Delete") final String confirmText; + + /// Text for cancel button (default: "Cancel") final String cancelText; + + /// Icon shown in the dialog header (default: Icons.delete) final IconData icon; + + /// Icon for confirm button (default: Icons.delete_forever) + final IconData confirmIcon; + + /// Icon for cancel button (default: Icons.close) + final IconData cancelIcon; + + /// Background color for confirm button (default: Colors.redAccent) final Color confirmColor; + + /// Callback fired when confirm is pressed and awaited. final Future Function() onConfirm; + + /// External RxBool to observe the loading state, if null internal is used final RxBool? isProcessing; + /// Custom error message shown in snackbar if confirmation fails + final String errorMessage; + + /// Text shown in confirm button while loading + final String loadingText; + const ConfirmDialog({ super.key, required this.title, @@ -21,28 +45,44 @@ class ConfirmDialog extends StatelessWidget { this.confirmText = "Delete", this.cancelText = "Cancel", this.icon = Icons.delete, + this.confirmIcon = Icons.delete_forever, + this.cancelIcon = Icons.close, this.confirmColor = Colors.redAccent, this.isProcessing, + this.errorMessage = "Failed to complete action. Try again.", + this.loadingText = "Submitting…", }); @override Widget build(BuildContext context) { - // Use provided RxBool, or create one internally final RxBool loading = isProcessing ?? false.obs; + final theme = Theme.of(context); return Dialog( + backgroundColor: theme.colorScheme.surface, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - child: _ContentView( - title: title, - message: message, - icon: icon, - confirmColor: confirmColor, - confirmText: confirmText, - cancelText: cancelText, - loading: loading, - onConfirm: onConfirm, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 280, + maxWidth: 480, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 16), + child: _ContentView( + title: title, + message: message, + icon: icon, + confirmColor: confirmColor, + confirmText: confirmText, + cancelText: cancelText, + confirmIcon: confirmIcon, + cancelIcon: cancelIcon, + loading: loading, + onConfirm: onConfirm, + errorMessage: errorMessage, + loadingText: loadingText, + ), ), ), ); @@ -50,11 +90,12 @@ class ConfirmDialog extends StatelessWidget { } class _ContentView extends StatelessWidget { - final String title, message, confirmText, cancelText; - final IconData icon; + final String title, message, confirmText, cancelText, loadingText; + final IconData icon, confirmIcon, cancelIcon; final Color confirmColor; final RxBool loading; final Future Function() onConfirm; + final String errorMessage; const _ContentView({ required this.title, @@ -63,71 +104,113 @@ class _ContentView extends StatelessWidget { required this.confirmColor, required this.confirmText, required this.cancelText, + required this.confirmIcon, + required this.cancelIcon, required this.loading, required this.onConfirm, + required this.errorMessage, + required this.loadingText, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 48, color: confirmColor), - const SizedBox(height: 16), - MyText.titleLarge( - title, - fontWeight: 600, - color: theme.colorScheme.onBackground, - ), - const SizedBox(height: 12), - MyText.bodySmall( - message, - textAlign: TextAlign.center, - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), - const SizedBox(height: 24), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Obx(() => _DialogButton( - text: cancelText, - icon: Icons.close, - color: Colors.grey, - isLoading: false, - onPressed: loading.value - ? null // disable while loading - : () => Navigator.pop(context, false), - )), + Container( + decoration: BoxDecoration( + color: confirmColor.withOpacity(0.12), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(10), + child: Icon( + icon, + size: 22, + color: confirmColor, + ), ), const SizedBox(width: 12), Expanded( - child: Obx(() => _DialogButton( - text: confirmText, - icon: Icons.delete_forever, - color: confirmColor, - isLoading: loading.value, - onPressed: () async { - try { - loading.value = true; - await onConfirm(); // 🔥 call API - Navigator.pop(context, true); // close on success - } catch (e) { - // Show error, dialog stays open - showAppSnackbar( - title: "Error", - message: "Failed to delete. Try again.", - type: SnackbarType.error, - ); - } finally { - loading.value = false; - } - }, - )), + child: MyText.titleLarge( + title, + fontWeight: 700, + color: colorScheme.onSurface, + ), ), ], ), + const SizedBox(height: 12), + MyText.bodyMedium( + message, + textAlign: TextAlign.left, + color: colorScheme.onSurface.withValues(alpha: 0.75), + ), + const SizedBox(height: 20), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.center, + child: SizedBox( + width: double.infinity, // allow full available width + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Obx( + () => _DialogButton( + text: cancelText, + icon: cancelIcon, + color: Colors.transparent, + textColor: colorScheme.onSurface, + isFilled: false, + isLoading: false, + onPressed: loading.value ? null : () => Navigator.pop(context, false), + ), + ), + ), + const SizedBox(width: 20), + Expanded( + child: Obx( + () => _DialogButton( + text: loading.value ? loadingText : confirmText, + icon: confirmIcon, + color: confirmColor, + textColor: Colors.white, + isFilled: true, + isLoading: loading.value, + onPressed: () async { + try { + loading.value = true; + await onConfirm(); + Navigator.pop(context, true); + } catch (e) { + showAppSnackbar( + title: "Error", + message: errorMessage, + type: SnackbarType.error, + ); + } finally { + loading.value = false; + } + }, + ), + ), + ), + ], + ), + ), +), + ], ); } @@ -137,6 +220,8 @@ class _DialogButton extends StatelessWidget { final String text; final IconData icon; final Color color; + final Color textColor; + final bool isFilled; final VoidCallback? onPressed; final bool isLoading; @@ -144,36 +229,73 @@ class _DialogButton extends StatelessWidget { required this.text, required this.icon, required this.color, + required this.textColor, + required this.isFilled, required this.onPressed, this.isLoading = false, }); @override Widget build(BuildContext context) { - return ElevatedButton.icon( - onPressed: isLoading ? null : onPressed, - icon: isLoading - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Icon(icon, color: Colors.white), - label: MyText.bodyMedium( - isLoading ? "Submitting.." : text, - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: color, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(vertical: 12), - ), + final theme = Theme.of(context); + + final ButtonStyle style = isFilled + ? ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: textColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + ) + : OutlinedButton.styleFrom( + foregroundColor: textColor, + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.7), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ); + + final Widget iconWidget = isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isFilled ? Colors.white : theme.colorScheme.primary, + ), + ) + : Icon(icon, size: 18); + + final Widget labelWidget = MyText.bodyMedium( + text, + color: isFilled ? Colors.white : textColor, + fontWeight: 600, ); + + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + iconWidget, + const SizedBox(width: 8), + Flexible(child: labelWidget), + ], + ); + + return isFilled + ? ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: style, + child: child, + ) + : OutlinedButton( + onPressed: isLoading ? null : onPressed, + style: style, + child: child, + ); } } diff --git a/lib/view/service_project/jobs_tab.dart b/lib/view/service_project/jobs_tab.dart new file mode 100644 index 0000000..bc18038 --- /dev/null +++ b/lib/view/service_project/jobs_tab.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/controller/service_project/service_project_details_screen_controller.dart'; +import 'package:marco/view/service_project/service_project_job_detail_screen.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; + +class JobsTab extends StatefulWidget { + final ScrollController scrollController; + + const JobsTab({super.key, required this.scrollController}); + + @override + _JobsTabState createState() => _JobsTabState(); +} + +class _JobsTabState extends State { + final TextEditingController searchController = TextEditingController(); + late ServiceProjectDetailsController controller; + + @override + void initState() { + super.initState(); + controller = Get.find(); + + // Ensure only active jobs are displayed initially + controller.showArchivedJobs.value = false; + controller.fetchProjectJobs(refresh: true); + + searchController.addListener(() { + controller.updateJobSearch(searchController.text); + }); + } + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + 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(); + controller.updateJobSearch(''); + }, + ); + }, + ), + hintText: 'Search jobs...', + 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), + ), + ), + ), + ), + ), + const SizedBox(width: 10), + Container( + height: 35, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + ), + child: Obx(() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Archived", + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + Switch( + value: controller.showArchivedJobs.value, + onChanged: (val) { + controller.showArchivedJobs.value = val; + controller.fetchProjectJobs(refresh: true); + }, + activeColor: Colors.blue, + inactiveThumbColor: Colors.grey, + ), + ], + ); + }), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildSearchBar(), + Expanded( + child: Obx(() { + if (controller.isJobLoading.value && + controller.filteredJobList.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.jobErrorMessage.value.isNotEmpty && + controller.filteredJobList.isEmpty) { + return Center( + child: MyText.bodyMedium(controller.jobErrorMessage.value)); + } + + if (controller.filteredJobList.isEmpty) { + return Center(child: MyText.bodyMedium("No jobs found")); + } + + return ListView.separated( + controller: widget.scrollController, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + itemCount: controller.filteredJobList.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == controller.filteredJobList.length) { + return controller.hasMoreJobs.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + + final job = controller.filteredJobList[index]; + + return Stack( + children: [ + AbsorbPointer( + absorbing: controller.showArchivedJobs + .value, // Disable interactions if archived + child: InkWell( + onTap: () { + if (!controller.showArchivedJobs.value) { + 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], + ), + 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), + 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), + Row( + children: [ + 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(), + 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, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + Obx(() { + final isArchivedJob = controller.showArchivedJobs.value; + if (job.status.id != JobStatus.closed && + job.status.id != JobStatus.reviewDone) { + return const SizedBox.shrink(); + } + + return Positioned( + top: 10, + right: 10, + child: GestureDetector( + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: isArchivedJob + ? "Restore Job?" + : "Archive Job?", + message: isArchivedJob + ? "Are you sure you want to restore this job?" + : "Are you sure you want to archive this job?", + confirmText: "Yes", + cancelText: "Cancel", + icon: isArchivedJob + ? Icons.restore + : Icons.archive_outlined, + confirmColor: Colors.green, + confirmIcon: Icons.check, + onConfirm: () async { + final operations = [ + { + "op": "replace", + "path": "/isArchive", + "value": isArchivedJob ? false : true + } + ]; + + final success = + await ApiService.editServiceProjectJobApi( + jobId: job.id , + operations: operations, + ); + + if (success) { + showAppSnackbar( + title: "Success", + message: isArchivedJob + ? "Job restored successfully" + : "Job archived successfully", + type: SnackbarType.success, + ); + controller.fetchProjectJobs(refresh: true); + } else { + showAppSnackbar( + title: "Error", + message: isArchivedJob + ? "Failed to restore job. Please try again." + : "Failed to archive job. Please try again.", + type: SnackbarType.error, + ); + } + }, + ), + ); + + if (confirmed != true) return; + }, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: isArchivedJob ? Colors.blue : Colors.red, + shape: BoxShape.circle, + ), + child: Icon( + isArchivedJob + ? Icons.restore + : Icons.archive_outlined, + size: 20, + color: Colors.white, + ), + ), + ), + ); + }), + ], + ); + }, + ); + }), + ), + ], + ); + } +} diff --git a/lib/view/service_project/service_project_details_screen.dart b/lib/view/service_project/service_project_details_screen.dart index 01895bf..1105ee9 100644 --- a/lib/view/service_project/service_project_details_screen.dart +++ b/lib/view/service_project/service_project_details_screen.dart @@ -7,12 +7,11 @@ 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'; -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'; import 'package:marco/model/service_project/service_project_allocation_bottomsheet.dart'; import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/view/service_project/jobs_tab.dart'; class ServiceProjectDetailsScreen extends StatefulWidget { final String projectId; @@ -319,149 +318,89 @@ class _ServiceProjectDetailsScreenState ); } - Widget _buildJobsTab() { + Widget _buildTeamsTab() { return Obx(() { - if (controller.isJobLoading.value && controller.jobList.isEmpty) { + if (controller.isTeamLoading.value) { return const Center(child: CircularProgressIndicator()); } - if (controller.jobErrorMessage.value.isNotEmpty && - controller.jobList.isEmpty) { + if (controller.teamErrorMessage.value.isNotEmpty && + controller.teamList.isEmpty) { return Center( - child: MyText.bodyMedium(controller.jobErrorMessage.value)); + child: MyText.bodyMedium(controller.teamErrorMessage.value)); } - if (controller.jobList.isEmpty) { - return Center(child: MyText.bodyMedium("No jobs found")); + if (controller.teamList.isEmpty) { + return Center(child: MyText.bodyMedium("No team members found")); + } + + // Group team members by their role ID + final Map roleGroups = {}; + for (var team in controller.teamList) { + roleGroups.putIfAbsent(team.teamRole.id, () => []).add(team); } return ListView.separated( - controller: _jobScrollController, - padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), - itemCount: controller.jobList.length + 1, + padding: const EdgeInsets.all(12), + itemCount: roleGroups.keys.length, 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 roleId = roleGroups.keys.elementAt(index); + final teamMembers = roleGroups[roleId]!; + final roleName = teamMembers.first.teamRole.name; - final job = controller.jobList[index]; - 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], - ), - - // 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: 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), - ), - - 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), + return Card( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 3, + shadowColor: Colors.black26, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Role header + MyText.bodyLarge( + roleName, + fontWeight: 700, + color: Colors.black87, + ), + const Divider(height: 20, thickness: 1), + // List of team members inside this role card + ...teamMembers.map((team) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Avatar( + firstName: team.employee.firstName, + lastName: team.employee.lastName, + size: 32, + imageUrl: (team.employee.photo?.isNotEmpty ?? false) + ? team.employee.photo + : null, ), - child: Text( - job.status.displayName, - style: TextStyle( - fontSize: 12, - color: - job.status.name.toLowerCase() == 'completed' - ? Colors.green[800] - : Colors.orange[800], - fontWeight: FontWeight.w600, + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + "${team.employee.firstName} ${team.employee.lastName}", + fontWeight: 600, + ), + MyText.bodySmall( + "Status: ${team.isActive ? 'Active' : 'Inactive'}", + color: Colors.grey[700], + ), + ], ), ), - ), - ], - ), - ], - ), + ], + ), + ); + }).toList(), + ], ), ), ); @@ -470,96 +409,6 @@ class _ServiceProjectDetailsScreenState }); } - Widget _buildTeamsTab() { - return Obx(() { - if (controller.isTeamLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - - if (controller.teamErrorMessage.value.isNotEmpty && - controller.teamList.isEmpty) { - return Center(child: MyText.bodyMedium(controller.teamErrorMessage.value)); - } - - if (controller.teamList.isEmpty) { - return Center(child: MyText.bodyMedium("No team members found")); - } - - // Group team members by their role ID - final Map roleGroups = {}; - for (var team in controller.teamList) { - roleGroups.putIfAbsent(team.teamRole.id, () => []).add(team); - } - - return ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: roleGroups.keys.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final roleId = roleGroups.keys.elementAt(index); - final teamMembers = roleGroups[roleId]!; - final roleName = teamMembers.first.teamRole.name; - - return Card( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - elevation: 3, - shadowColor: Colors.black26, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Role header - MyText.bodyLarge( - roleName, - fontWeight: 700, - color: Colors.black87, - ), - const Divider(height: 20, thickness: 1), - // List of team members inside this role card - ...teamMembers.map((team) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Avatar( - firstName: team.employee.firstName, - lastName: team.employee.lastName, - size: 32, - imageUrl: (team.employee.photo?.isNotEmpty ?? false) - ? team.employee.photo - : null, - ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - "${team.employee.firstName} ${team.employee.lastName}", - fontWeight: 600, - ), - MyText.bodySmall( - "Status: ${team.isActive ? 'Active' : 'Inactive'}", - color: Colors.grey[700], - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ], - ), - ), - ); - }, - ); - }); -} - - @override Widget build(BuildContext context) { return Scaffold( @@ -606,7 +455,7 @@ class _ServiceProjectDetailsScreenState controller: _tabController, children: [ _buildProfileTab(), - _buildJobsTab(), + JobsTab(scrollController: _jobScrollController), _buildTeamsTab(), ], ); diff --git a/lib/view/service_project/service_project_screen.dart b/lib/view/service_project/service_project_screen.dart index 50bdee7..e26d7a3 100644 --- a/lib/view/service_project/service_project_screen.dart +++ b/lib/view/service_project/service_project_screen.dart @@ -246,62 +246,6 @@ class _ServiceProjectScreenState extends State ), ), ), - MySpacing.width(8), - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(5), - ), - child: IconButton( - icon: - const Icon(Icons.tune, size: 20, color: Colors.black87), - onPressed: () { - // TODO: Open filter bottom sheet - }, - ), - ), - MySpacing.width(10), - 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) => [ - const PopupMenuItem( - enabled: false, - height: 30, - child: Text( - "Actions", - style: TextStyle( - fontWeight: FontWeight.bold, color: Colors.grey), - ), - ), - const PopupMenuItem( - value: 1, - child: Row( - children: [ - SizedBox(width: 10), - Expanded(child: Text("Manage Projects")), - Icon(Icons.chevron_right, - size: 20, color: Colors.indigo), - ], - ), - ), - ], - ), - ), ], ), ),