Add job status management to service project details screen

This commit is contained in:
Vaibhav Surve 2025-11-28 18:30:08 +05:30
parent 341d779499
commit 7bef2e9d89
6 changed files with 334 additions and 27 deletions

View File

@ -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 = <JobEntity>[].obs;
// -------------------- Job Status --------------------
// With this:
var jobStatusList = <JobStatus>[].obs;
var selectedJobStatus = Rx<JobStatus?>(null);
var isJobStatusLoading = false.obs;
var jobStatusErrorMessage = ''.obs;
// -------------------- Lifecycle --------------------
@override
@ -110,6 +117,41 @@ class ServiceProjectDetailsController extends GetxController {
}
}
Future<void> 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<void> fetchProjectDetail() async {
if (projectId.value.isEmpty) {
errorMessage.value = "Invalid project ID";

View File

@ -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";
}

View File

@ -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<List<JobStatus>?> 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<dynamic>?)
?.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<ServiceProjectBranchesResponse?> getServiceProjectBranchesFull({
required String projectId,

View File

@ -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(

View File

@ -0,0 +1,85 @@
class JobStatusResponse {
final bool? success;
final String? message;
final List<JobStatus>? 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<String, dynamic> json) {
return JobStatusResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: (json['data'] as List<dynamic>?)
?.map((e) => JobStatus.fromJson(e))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] as String?,
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return JobStatus(
id: json['id'] as String?,
name: json['name'] as String?,
displayName: json['displayName'] as String?,
level: json['level'] as int?,
);
}
Map<String, dynamic> 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;
}

View File

@ -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<JobDetailsScreen> with UIMixin {
void initState() {
super.initState();
controller = Get.find<ServiceProjectDetailsController>();
// 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<JobDetailsScreen> 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<JobDetailsScreen> with UIMixin {
}
Future<void> _editJob() async {
_processTagsInput();
_processTagsInput(); // process any new tag input
final job = controller.jobDetail.value?.data;
if (job == null) return;
final List<Map<String, dynamic>> 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<JobDetailsScreen> 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<JobDetailsScreen> 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<Map<String, dynamic>> 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<JobDetailsScreen> 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<JobDetailsScreen> with UIMixin {
return;
}
// 8 Call API
final success = await ApiService.editServiceProjectJobApi(
jobId: job.id ?? "",
operations: operations,
@ -199,16 +220,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> 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<JobDetailsScreen> 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<JobStatus>(
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<JobDetailsScreen> with UIMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildJobStatusCard(),
_buildAttendanceCard(),
_buildSectionCard(
title: "Job Info",