From 7bef2e9d895b3e022e9fd3f5263bde6c3b381ceb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 28 Nov 2025 18:30:08 +0530 Subject: [PATCH] Add job status management to service project details screen --- ...ice_project_details_screen_controller.dart | 42 ++++ lib/helpers/services/api_endpoints.dart | 6 +- lib/helpers/services/api_service.dart | 39 ++++ lib/helpers/widgets/custom_app_bar.dart | 5 +- .../service_project/job_status_response.dart | 85 ++++++++ .../service_project_job_detail_screen.dart | 184 +++++++++++++++--- 6 files changed, 334 insertions(+), 27 deletions(-) create mode 100644 lib/model/service_project/job_status_response.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 46f43aa..a3559e7 100644 --- a/lib/controller/service_project/service_project_details_screen_controller.dart +++ b/lib/controller/service_project/service_project_details_screen_controller.dart @@ -6,6 +6,7 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m import 'package:geolocator/geolocator.dart'; import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart'; import 'package:on_field_work/model/service_project/job_allocation_model.dart'; +import 'package:on_field_work/model/service_project/job_status_response.dart'; import 'dart:convert'; import 'dart:io'; @@ -41,6 +42,12 @@ class ServiceProjectDetailsController extends GetxController { var isTeamLoading = false.obs; var teamErrorMessage = ''.obs; var filteredJobList = [].obs; +// -------------------- Job Status -------------------- +// With this: + var jobStatusList = [].obs; + var selectedJobStatus = Rx(null); + var isJobStatusLoading = false.obs; + var jobStatusErrorMessage = ''.obs; // -------------------- Lifecycle -------------------- @override @@ -110,6 +117,41 @@ class ServiceProjectDetailsController extends GetxController { } } + Future fetchJobStatus({required String statusId}) async { + if (projectId.value.isEmpty) { + jobStatusErrorMessage.value = "Invalid project ID"; + return; + } + + isJobStatusLoading.value = true; + jobStatusErrorMessage.value = ''; + + try { + final statuses = await ApiService.getMasterJobStatus( + projectId: projectId.value, + statusId: statusId, + ); + + if (statuses != null && statuses.isNotEmpty) { + jobStatusList.value = statuses; + + // Keep previously selected if exists, else pick first + selectedJobStatus.value = statuses.firstWhere( + (status) => status.id == selectedJobStatus.value?.id, + orElse: () => statuses.first, + ); + + print("Job Status List: ${jobStatusList.map((e) => e.name).toList()}"); + } else { + jobStatusErrorMessage.value = "No job statuses found"; + } + } catch (e) { + jobStatusErrorMessage.value = "Error fetching job status: $e"; + } finally { + isJobStatusLoading.value = false; + } + } + Future fetchProjectDetail() async { if (projectId.value.isEmpty) { errorMessage.value = "Invalid project ID"; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f68ad6f..f7f5759 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"; @@ -152,4 +152,6 @@ class ApiEndpoints { static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; static const String getTeamRoles = "/master/team-roles/list"; static const String getServiceProjectBranches = "/serviceproject/branch/list"; + + static const String getMasterJobStatus = "/Master/job-status/list"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 95cd499..2a63441 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -40,6 +40,7 @@ import 'package:on_field_work/model/service_project/service_project_job_detail_m import 'package:on_field_work/model/service_project/job_attendance_logs_model.dart'; import 'package:on_field_work/model/service_project/job_allocation_model.dart'; import 'package:on_field_work/model/service_project/service_project_branches_model.dart'; +import 'package:on_field_work/model/service_project/job_status_response.dart'; class ApiService { static const bool enableLogs = true; @@ -311,6 +312,44 @@ class ApiService { } } + static Future?> getMasterJobStatus({ + required String statusId, + required String projectId, + }) async { + final queryParams = { + 'statusId': statusId, + 'projectId': projectId, + }; + + try { + final response = await _getRequest( + ApiEndpoints.getMasterJobStatus, + queryParams: queryParams, + ); + + if (response == null) { + _log("getMasterJobStatus: No response received."); + return null; + } + + final parsedJson = + _parseResponseForAllData(response, label: "MasterJobStatus"); + + if (parsedJson == null) return null; + + // Directly parse JobStatus list + final dataList = (parsedJson['data'] as List?) + ?.map((e) => JobStatus.fromJson(e)) + .toList(); + + return dataList; + } catch (e, stack) { + _log("Exception in getMasterJobStatus: $e\n$stack", + level: LogLevel.error); + return null; + } + } + /// Fetch Service Project Branches with full response static Future getServiceProjectBranchesFull({ required String projectId, diff --git a/lib/helpers/widgets/custom_app_bar.dart b/lib/helpers/widgets/custom_app_bar.dart index fbcd2e0..fc2efd4 100644 --- a/lib/helpers/widgets/custom_app_bar.dart +++ b/lib/helpers/widgets/custom_app_bar.dart @@ -36,9 +36,8 @@ class CustomAppBar extends StatelessWidget elevation: 0, automaticallyImplyLeading: false, titleSpacing: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(0)), - ), + shadowColor: Colors.transparent, + leading: Padding( padding: MySpacing.only(left: horizontalPadding), child: IconButton( diff --git a/lib/model/service_project/job_status_response.dart b/lib/model/service_project/job_status_response.dart new file mode 100644 index 0000000..d4edc23 --- /dev/null +++ b/lib/model/service_project/job_status_response.dart @@ -0,0 +1,85 @@ +class JobStatusResponse { + final bool? success; + final String? message; + final List? data; + final dynamic errors; + final int? statusCode; + final String? timestamp; + + JobStatusResponse({ + this.success, + this.message, + this.data, + this.errors, + this.statusCode, + this.timestamp, + }); + + factory JobStatusResponse.fromJson(Map json) { + return JobStatusResponse( + success: json['success'] as bool?, + message: json['message'] as String?, + data: (json['data'] as List?) + ?.map((e) => JobStatus.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] as int?, + timestamp: json['timestamp'] as String?, + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data?.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; + } +} + +// -------------------------- +// Single Job Status Model +// -------------------------- +class JobStatus { + final String? id; + final String? name; + final String? displayName; + final int? level; + + JobStatus({ + this.id, + this.name, + this.displayName, + this.level, + }); + + factory JobStatus.fromJson(Map json) { + return JobStatus( + id: json['id'] as String?, + name: json['name'] as String?, + displayName: json['displayName'] as String?, + level: json['level'] as int?, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'displayName': displayName, + 'level': level, + }; + } + + // ✅ Add equality by id + @override + bool operator ==(Object other) => + identical(this, other) || + other is JobStatus && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} 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 4805ded..098d8e0 100644 --- a/lib/view/service_project/service_project_job_detail_screen.dart +++ b/lib/view/service_project/service_project_job_detail_screen.dart @@ -18,6 +18,7 @@ import 'dart:io'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; +import 'package:on_field_work/model/service_project/job_status_response.dart'; class JobDetailsScreen extends StatefulWidget { final String jobId; @@ -48,10 +49,12 @@ class _JobDetailsScreenState extends State with UIMixin { void initState() { super.initState(); controller = Get.find(); - // fetch and seed local selected lists - controller.fetchJobDetail(widget.jobId).then((_) { + + // Fetch job detail first + controller.fetchJobDetail(widget.jobId).then((_) async { final job = controller.jobDetail.value?.data; if (job != null) { + // Populate form fields _selectedTags.value = (job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList(); _titleController.text = job.title ?? ''; @@ -63,6 +66,21 @@ class _JobDetailsScreenState extends State with UIMixin { job.dueDate ?? '', format: "yyyy-MM-dd"); _selectedAssignees.value = job.assignees ?? []; + + // 🔹 Fetch job status only if existing status ID present + final existingStatusId = job.status?.id; + if (existingStatusId != null) { + await controller.fetchJobStatus(statusId: existingStatusId); + + // Set selectedJobStatus to match existing status ID + if (controller.jobStatusList.isNotEmpty) { + controller.selectedJobStatus.value = + controller.jobStatusList.firstWhere( + (s) => s.id == existingStatusId, + orElse: () => controller.jobStatusList.first, + ); + } + } } }); } @@ -88,18 +106,20 @@ class _JobDetailsScreenState extends State with UIMixin { } Future _editJob() async { - _processTagsInput(); + _processTagsInput(); // process any new tag input final job = controller.jobDetail.value?.data; if (job == null) return; final List> operations = []; + // 1️⃣ Title final trimmedTitle = _titleController.text.trim(); if (trimmedTitle != job.title) { operations .add({"op": "replace", "path": "/title", "value": trimmedTitle}); } + // 2️⃣ Description final trimmedDescription = _descriptionController.text.trim(); if (trimmedDescription != job.description) { operations.add({ @@ -109,6 +129,7 @@ class _JobDetailsScreenState extends State with UIMixin { }); } + // 3️⃣ Start & Due Date final startDate = DateTime.tryParse(_startDateController.text); final dueDate = DateTime.tryParse(_dueDateController.text); @@ -128,32 +149,27 @@ class _JobDetailsScreenState extends State with UIMixin { }); } - // Assignees payload (keep same approach) + // 4️⃣ 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(); - // add newly added assignees for (var s in _selectedAssignees) { - if (!(originalAssignees.any((a) => a.id == s.id))) { + if (!originalAssignees.any((a) => a.id == s.id)) { assigneesPayload.add({"employeeId": s.id, "isActive": true}); } } - operations.add( {"op": "replace", "path": "/assignees", "value": assigneesPayload}); - // TAGS: build robust payload using original tags and current selection + // 5️⃣ Tags final originalTags = job.tags ?? []; final currentTags = _selectedTags.toList(); - - // Only add tags operation if something changed if (_tagsAreDifferent(originalTags, currentTags)) { final List> finalTagsPayload = []; - // 1) For existing original tags - we need to mark isActive true/false depending on whether they're in currentTags for (var ot in originalTags) { final isSelected = currentTags.any((ct) => (ct.id != null && ct.id == ot.id) || @@ -165,21 +181,25 @@ class _JobDetailsScreenState extends State with UIMixin { }); } - // 2) Add newly created tags from currentTags that don't have a valid id (id == "0" or null) for (var ct in currentTags.where((c) => c.id == null || c.id == "0")) { - finalTagsPayload.add({ - "name": ct.name, - "isActive": true, - }); + finalTagsPayload.add({"name": ct.name, "isActive": true}); } + operations + .add({"op": "replace", "path": "/tags", "value": finalTagsPayload}); + } + + // 6️⃣ Job Status + final selectedStatus = controller.selectedJobStatus.value; + if (selectedStatus != null && selectedStatus.id != job.status?.id) { operations.add({ "op": "replace", - "path": "/tags", - "value": finalTagsPayload, + "path": "/statusId", // make sure API expects this field + "value": selectedStatus.id }); } + // 7️⃣ Check if anything changed if (operations.isEmpty) { showAppSnackbar( title: "Info", @@ -188,6 +208,7 @@ class _JobDetailsScreenState extends State with UIMixin { return; } + // 8️⃣ Call API final success = await ApiService.editServiceProjectJobApi( jobId: job.id ?? "", operations: operations, @@ -199,16 +220,13 @@ class _JobDetailsScreenState extends State with UIMixin { message: "Job updated successfully", type: SnackbarType.success); - // re-fetch job detail and update local selected tags from server response + // Re-fetch job detail & update tags locally await controller.fetchJobDetail(widget.jobId); final updatedJob = controller.jobDetail.value?.data; - if (updatedJob != null) { _selectedTags.value = (updatedJob.tags ?? []) .map((t) => Tag(id: t.id, name: t.name)) .toList(); - - // UI refresh to reflect tags instantly setState(() {}); } @@ -799,6 +817,127 @@ class _JobDetailsScreenState extends State with UIMixin { ); } + Widget _buildJobStatusCard() { + final job = controller.jobDetail.value?.data; + if (job == null) return const SizedBox(); + + // Existing status info + final statusName = job.status?.displayName ?? "N/A"; + Color statusColor; + switch (job.status?.level) { + case 1: + statusColor = Colors.green; + break; + case 2: + statusColor = Colors.orange; + break; + case 3: + statusColor = Colors.blue; + break; + case 4: + statusColor = Colors.red; + break; + default: + statusColor = Colors.grey; + } + + final editing = isEditing.value; + + // Ensure selectedJobStatus initialized + if (editing && controller.selectedJobStatus.value == null) { + final existingStatusId = job.status?.id; + if (existingStatusId != null && controller.jobStatusList.isNotEmpty) { + controller.selectedJobStatus.value = + controller.jobStatusList.firstWhere( + (s) => s.id == existingStatusId, + orElse: () => controller.jobStatusList.first, + ); + } + } + + return _buildSectionCard( + title: "Job Status", + titleIcon: Icons.flag_outlined, + children: [ + // 1️⃣ Display existing status + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: statusColor.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon(Icons.flag, color: statusColor, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: statusColor), + ), + const SizedBox(height: 2), + Text( + "Level: ${job.status?.level ?? '-'}", + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 2️⃣ PopupMenuButton for new selection + if (editing) + Obx(() { + final selectedStatus = controller.selectedJobStatus.value; + final statuses = controller.jobStatusList; + + return PopupMenuButton( + onSelected: (val) => controller.selectedJobStatus.value = val, + itemBuilder: (_) => statuses + .map( + (s) => PopupMenuItem( + value: s, + child: Text(s.displayName ?? "N/A"), + ), + ) + .toList(), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedStatus?.displayName ?? "Select Job Status", + style: + TextStyle(color: Colors.grey.shade700, fontSize: 14), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + }), + ], + ); + } + @override Widget build(BuildContext context) { final projectName = widget.projectName; @@ -879,6 +1018,7 @@ class _JobDetailsScreenState extends State with UIMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildJobStatusCard(), _buildAttendanceCard(), _buildSectionCard( title: "Job Info",