From 10e9f4a315a386ebff2ed3d0eeaece5d7594ba35 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 17 Nov 2025 14:49:44 +0530 Subject: [PATCH] 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), ], ),