Refactor service project job handling and improve tag management

This commit is contained in:
Vaibhav Surve 2025-11-28 15:34:13 +05:30
parent 65fbef3441
commit 341d779499
7 changed files with 278 additions and 209 deletions

View File

@ -63,26 +63,34 @@ class AddServiceProjectJobController extends GetxController {
return;
}
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
isLoading.value = true;
final success = await ApiService.createServiceProjectJobApi(
final jobId = await ApiService.createServiceProjectJobApi(
title: titleCtrl.text.trim(),
description: descCtrl.text.trim(),
projectId: projectId,
branchId: selectedBranch.value?.id,
assignees: assigneeIds.map((id) => {"id": id}).toList(),
branchId: selectedBranch.value?.id,
assignees: selectedAssignees // payload mapping
.map((e) => {"employeeId": e.id, "isActive": true})
.toList(),
startDate: startDate.value!,
dueDate: dueDate.value!,
tags: enteredTags.map((tag) => {"name": tag}).toList(),
tags: enteredTags
.map((tag) => {"id": null, "name": tag, "isActive": true})
.toList(),
);
isLoading.value = false;
if (success) {
if (jobId != null) {
if (Get.isRegistered<ServiceProjectDetailsController>()) {
Get.find<ServiceProjectDetailsController>().refreshJobsAfterAdd();
final detailsCtrl = Get.find<ServiceProjectDetailsController>();
// 🔥 1. Refresh job LIST
detailsCtrl.refreshJobsAfterAdd();
// 🔥 2. Refresh job DETAILS (FULL DATA - including tags and assignees)
await detailsCtrl.fetchJobDetail(jobId);
}
Get.back();

View File

@ -595,8 +595,7 @@ class ApiService {
return null;
}
/// Create a new Service Project Job
static Future<bool> createServiceProjectJobApi({
static Future<String?> createServiceProjectJobApi({
required String title,
required String description,
required String projectId,
@ -623,32 +622,22 @@ class ApiService {
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Service Project Job failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Create Service Project Job response status: ${response.statusCode}");
logSafe("Create Service Project Job response body: ${response.body}");
if (response == null) return null;
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Service Project Job created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
final jobId = json['data']?['id'];
logSafe("Service Project Job created successfully: $jobId");
return jobId;
}
return null;
} catch (e, stack) {
logSafe("Exception during createServiceProjectJobApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
return null;
}
}
@ -671,8 +660,7 @@ class ApiService {
'pageNumber': pageNumber.toString(),
'pageSize': pageSize.toString(),
'isActive': isActive.toString(),
if (isArchive)
'isArchive': 'true',
if (isArchive) 'isArchive': 'true',
};
final response = await _getRequest(endpoint, queryParams: queryParams);

View File

@ -54,44 +54,50 @@ class CustomAppBar extends StatelessWidget
),
title: Padding(
padding: MySpacing.only(right: horizontalPadding, left: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
child: Row(
children: [
MyText.titleLarge(
title,
fontWeight: 800,
color: onPrimaryColor,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
MySpacing.height(3),
GetBuilder<ProjectController>(
builder: (projectController) {
final displayProjectName = projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: MyText.bodySmall(
displayProjectName,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
),
),
MySpacing.width(2),
const Icon(Icons.keyboard_arrow_down,
size: 18, color: onPrimaryColor),
],
);
},
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyText.titleLarge(
title,
fontWeight: 800,
color: onPrimaryColor,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
MySpacing.height(3),
GetBuilder<ProjectController>(
builder: (projectController) {
final displayProjectName = projectName ??
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.folder_open,
size: 14, color: onPrimaryColor),
MySpacing.width(4),
Flexible(
child: MyText.bodySmall(
displayProjectName,
fontWeight: 500,
color: onPrimaryColor.withOpacity(0.8),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
MySpacing.width(2),
const Icon(Icons.keyboard_arrow_down,
size: 18, color: onPrimaryColor),
],
);
},
),
],
),
),
],
),

View File

@ -220,8 +220,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
}
}
@ -239,8 +240,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
}
controller.tagCtrl.clear();
@ -256,8 +258,9 @@ class _AddServiceProjectJobBottomSheetState
.where((s) => s.isNotEmpty);
for (final p in parts) {
if (!controller.enteredTags.contains(p)) {
controller.enteredTags.add(p);
final clean = p.replaceAll('_', ' ');
if (!controller.enteredTags.contains(clean)) {
controller.enteredTags.add(clean);
}
}
controller.tagCtrl.clear();

View File

@ -324,9 +324,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? '';
final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble();
@ -356,16 +353,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
style:
TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
if (formattedTime.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
formattedTime,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic),
),
]
],
),
const SizedBox(height: 4),

View File

@ -247,6 +247,43 @@ class _UserProfileBarState extends State<UserProfileBar>
final tenants = tenantSwitchController.tenants;
if (tenants.isEmpty) return _noTenantContainer();
// If only one organization, don't show switch option
if (tenants.length == 1) {
final selectedTenant = tenants.first;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
color: Colors.grey.shade200,
child: TenantLogo(logoImage: selectedTenant.logoImage),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
selectedTenant.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: contentTheme.primary),
),
),
const Icon(Icons.check_circle, color: Colors.green, size: 18),
],
),
);
}
final selectedTenant = TenantService.currentTenant;

View File

@ -47,11 +47,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
@override
void initState() {
super.initState();
controller = Get.put(ServiceProjectDetailsController());
controller = Get.find<ServiceProjectDetailsController>();
// fetch and seed local selected lists
controller.fetchJobDetail(widget.jobId).then((_) {
final job = controller.jobDetail.value?.data;
if (job != null) {
_selectedTags.value = job.tags ?? [];
_selectedTags.value =
(job.tags ?? []).map((t) => Tag(id: t.id, name: t.name)).toList();
_titleController.text = job.title ?? '';
_descriptionController.text = job.description ?? '';
_startDateController.text = DateTimeUtils.convertUtcToLocal(
@ -61,7 +63,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
job.dueDate ?? '',
format: "yyyy-MM-dd");
_selectedAssignees.value = job.assignees ?? [];
_selectedTags.value = job.tags ?? [];
}
});
}
@ -76,7 +77,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
super.dispose();
}
bool _tagsAreDifferent(List<Tag> original, List<Tag> current) {
// Compare by id / name sets (simple equality)
final origIds = original.map((t) => t.id ?? '').toSet();
final currIds = current.map((t) => t.id ?? '').toSet();
final origNames = original.map((t) => t.name?.trim() ?? '').toSet();
final currNames = current.map((t) => t.name?.trim() ?? '').toSet();
return !(origIds == currIds && origNames == currNames);
}
Future<void> _editJob() async {
_processTagsInput();
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -116,39 +128,56 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
});
}
final originalAssignees = job.assignees;
final assigneesPayload = originalAssignees?.map((a) {
// Assignees payload (keep same approach)
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) ?? false)) {
assigneesPayload?.add({"employeeId": s.id, "isActive": true});
if (!(originalAssignees.any((a) => a.id == s.id))) {
assigneesPayload.add({"employeeId": s.id, "isActive": true});
}
}
operations.add(
{"op": "replace", "path": "/assignees", "value": assigneesPayload});
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();
// TAGS: build robust payload using original tags and current selection
final originalTags = job.tags ?? [];
final currentTags = _selectedTags.toList();
final addTagsPayload = _selectedTags
.where((t) => t.id == "0")
.map((t) => {"name": t.name, "isActive": true})
.toList();
// Only add tags operation if something changed
if (_tagsAreDifferent(originalTags, currentTags)) {
final List<Map<String, dynamic>> finalTagsPayload = [];
if ((replaceTagsPayload?.isNotEmpty ?? false)) {
operations
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload});
}
// 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) ||
(ct.name?.trim() == ot.name?.trim()));
finalTagsPayload.add({
"id": ot.id,
"name": ot.name,
"isActive": isSelected,
});
}
if (addTagsPayload.isNotEmpty) {
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload});
// 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,
});
}
operations.add({
"op": "replace",
"path": "/tags",
"value": finalTagsPayload,
});
}
if (operations.isEmpty) {
@ -169,10 +198,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
title: "Success",
message: "Job updated successfully",
type: SnackbarType.success);
// re-fetch job detail and update local selected tags from server response
await controller.fetchJobDetail(widget.jobId);
final updatedJob = controller.jobDetail.value?.data;
if (updatedJob != null) {
_selectedTags.value = updatedJob.tags ?? [];
_selectedTags.value = (updatedJob.tags ?? [])
.map((t) => Tag(id: t.id, name: t.name))
.toList();
// UI refresh to reflect tags instantly
setState(() {});
}
isEditing.value = false;
@ -184,6 +221,29 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
}
}
void _processTagsInput() {
final input = _tagTextController.text;
// Remove comma behaviour treat whole input as one tag
String tag = input.trim();
if (tag.isEmpty) {
_tagTextController.clear();
return;
}
// Convert underscore to space
tag = tag.replaceAll("_", " ");
// Avoid duplicate tags (case-insensitive)
if (!_selectedTags
.any((t) => (t.name ?? "").toLowerCase() == tag.toLowerCase())) {
_selectedTags.add(Tag(id: "0", name: tag));
}
// Clear text field
_tagTextController.clear();
}
Future<void> _handleTagAction() async {
final job = controller.jobDetail.value?.data;
if (job == null) return;
@ -408,10 +468,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
border: Border.all(color: Colors.grey.shade400),
),
alignment: Alignment.centerLeft,
child: Text(
"Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
child: Text("Tap to select assignees",
style: TextStyle(fontSize: 14, color: Colors.grey[700])),
),
),
],
@ -422,19 +480,24 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Widget _tagEditor() {
return Obx(() {
final editing = isEditing.value;
final tags = _selectedTags;
final job = controller.jobDetail.value?.data;
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: tags
children: displayTags
.map(
(t) => Chip(
label: Text(t.name ?? ''),
onDeleted: editing
? () {
_selectedTags.remove(t);
_selectedTags.removeWhere((x) =>
(x.id != null && x.id == t.id) ||
(x.name == t.name));
}
: null,
),
@ -445,17 +508,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (editing)
TextField(
controller: _tagTextController,
onSubmitted: (v) {
final value = v.trim();
if (value.isNotEmpty && !tags.any((t) => t.name == value)) {
_selectedTags.add(Tag(id: "0", name: value));
onChanged: (value) {
// If space or comma typed process tags immediately
if (value.endsWith(" ") || value.contains(",")) {
_processTagsInput();
}
_tagTextController.clear();
},
onSubmitted: (_) {
// Still supports ENTER
_processTagsInput();
},
decoration: InputDecoration(
hintText: "Type and press enter to add tags",
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
hintText: "Type tags (space or comma to add multiple tags)",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
@ -495,11 +562,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
const Spacer(),
Obx(() => IconButton(
icon: Icon(
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600],
),
isAttendanceExpanded.value
? Icons.expand_less
: Icons.expand_more,
color: Colors.grey[600]),
onPressed: () async {
isAttendanceExpanded.value =
!isAttendanceExpanded.value;
@ -526,22 +592,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
height: 16,
width: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
color: Colors.white, strokeWidth: 2))
: Icon(action == 0 ? Icons.login : Icons.logout),
label: MyText.bodyMedium(
action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600,
color: Colors.white,
),
action == 0 ? "Tag In" : "Tag Out",
fontWeight: 600,
color: Colors.white),
onPressed: isLoading ? null : _handleTagAction,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
borderRadius: BorderRadius.circular(5)),
backgroundColor:
action == 0 ? Colors.green : Colors.red,
),
@ -563,10 +624,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
if (logs.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: MyText.bodyMedium(
"No attendance logs available",
color: Colors.grey[600],
),
child: MyText.bodyMedium("No attendance logs available",
color: Colors.grey[600]),
);
}
@ -601,25 +660,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Row(
children: [
Icon(
log.action == 0 ? Icons.login : Icons.logout,
color: log.action == 0
? Colors.green
: Colors.red,
size: 18,
),
log.action == 0
? Icons.login
: Icons.logout,
color: log.action == 0
? Colors.green
: Colors.red,
size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
employeeName,
style: const TextStyle(
fontWeight: FontWeight.w600),
),
),
Text(
"$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700]),
),
child: Text(employeeName,
style: const TextStyle(
fontWeight: FontWeight.w600))),
Text("$date | $time",
style: TextStyle(
fontSize: 12, color: Colors.grey[700])),
],
),
const SizedBox(height: 4),
@ -627,12 +682,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
// Comment
if (log.comment?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
log.comment!,
style: const TextStyle(fontSize: 13),
),
),
padding: const EdgeInsets.only(top: 4),
child: Text(log.comment!,
style: const TextStyle(fontSize: 13))),
// Location
if (log.latitude != null && log.longitude != null)
@ -657,14 +709,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
Icon(Icons.location_on,
size: 14, color: Colors.blue),
SizedBox(width: 4),
Text(
"View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration:
TextDecoration.underline),
),
Text("View Location",
style: TextStyle(
fontSize: 12,
color: Colors.blue,
decoration:
TextDecoration.underline)),
],
),
),
@ -679,16 +729,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
context: context,
builder: (_) => Dialog(
child: Image.network(
log.document!.preSignedUrl,
fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) =>
const Icon(
Icons.broken_image,
size: 50,
color: Colors.grey,
),
),
log.document!.preSignedUrl,
fit: BoxFit.cover,
height: 250,
errorBuilder: (_, __, ___) =>
const Icon(Icons.broken_image,
size: 50,
color: Colors.grey)),
),
),
child: ClipRRect(
@ -701,10 +748,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
width: 50,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 40,
color: Colors.grey,
),
Icons.broken_image,
size: 40,
color: Colors.grey),
),
),
),
@ -728,14 +774,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700),
),
Expanded(
flex: 5,
child: MyText.bodyMedium(value, fontWeight: 500),
),
flex: 3,
child: MyText.bodySmall(label,
fontWeight: 600, color: Colors.grey.shade700)),
Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
],
);
}
@ -912,12 +954,11 @@ class JobTimeline extends StatelessWidget {
isFirst: index == 0,
isLast: index == reversedLogs.length - 1,
indicatorStyle: const IndicatorStyle(
width: 16,
height: 16,
indicator: DecoratedBox(
decoration:
BoxDecoration(color: Colors.blue, shape: BoxShape.circle)),
),
width: 16,
height: 16,
indicator: DecoratedBox(
decoration: BoxDecoration(
color: Colors.blue, shape: BoxShape.circle))),
beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding(
padding: const EdgeInsets.all(12),
@ -932,13 +973,12 @@ class JobTimeline extends StatelessWidget {
const SizedBox(height: 10),
Row(children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600),
),
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4)),
child: MyText.bodySmall(initials, fontWeight: 600)),
const SizedBox(width: 6),
Expanded(child: MyText.bodySmall(updatedBy)),
]),