Add job status management to service project details screen
This commit is contained in:
parent
341d779499
commit
7bef2e9d89
@ -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";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
85
lib/model/service_project/job_status_response.dart
Normal file
85
lib/model/service_project/job_status_response.dart
Normal 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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user