Refactor service project job handling and improve tag management
This commit is contained in:
parent
65fbef3441
commit
341d779499
@ -63,26 +63,34 @@ class AddServiceProjectJobController extends GetxController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final assigneeIds = selectedAssignees.map((e) => e.id).toList();
|
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
final success = await ApiService.createServiceProjectJobApi(
|
final jobId = await ApiService.createServiceProjectJobApi(
|
||||||
title: titleCtrl.text.trim(),
|
title: titleCtrl.text.trim(),
|
||||||
description: descCtrl.text.trim(),
|
description: descCtrl.text.trim(),
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
branchId: selectedBranch.value?.id,
|
branchId: selectedBranch.value?.id,
|
||||||
assignees: assigneeIds.map((id) => {"id": id}).toList(),
|
assignees: selectedAssignees // payload mapping
|
||||||
|
.map((e) => {"employeeId": e.id, "isActive": true})
|
||||||
|
.toList(),
|
||||||
startDate: startDate.value!,
|
startDate: startDate.value!,
|
||||||
dueDate: dueDate.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;
|
isLoading.value = false;
|
||||||
|
|
||||||
if (success) {
|
if (jobId != null) {
|
||||||
if (Get.isRegistered<ServiceProjectDetailsController>()) {
|
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();
|
Get.back();
|
||||||
|
|||||||
@ -595,8 +595,7 @@ class ApiService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new Service Project Job
|
static Future<String?> createServiceProjectJobApi({
|
||||||
static Future<bool> createServiceProjectJobApi({
|
|
||||||
required String title,
|
required String title,
|
||||||
required String description,
|
required String description,
|
||||||
required String projectId,
|
required String projectId,
|
||||||
@ -623,32 +622,22 @@ class ApiService {
|
|||||||
try {
|
try {
|
||||||
final response = await _postRequest(endpoint, body);
|
final response = await _postRequest(endpoint, body);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) return 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}");
|
|
||||||
|
|
||||||
final json = jsonDecode(response.body);
|
final json = jsonDecode(response.body);
|
||||||
|
|
||||||
if (json['success'] == true) {
|
if (json['success'] == true) {
|
||||||
logSafe("Service Project Job created successfully: ${json['data']}");
|
final jobId = json['data']?['id'];
|
||||||
return true;
|
logSafe("Service Project Job created successfully: $jobId");
|
||||||
} else {
|
return jobId;
|
||||||
logSafe(
|
|
||||||
"Failed to create Service Project Job: ${json['message'] ?? 'Unknown error'}",
|
|
||||||
level: LogLevel.warning,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Exception during createServiceProjectJobApi: $e",
|
logSafe("Exception during createServiceProjectJobApi: $e",
|
||||||
level: LogLevel.error);
|
level: LogLevel.error);
|
||||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -671,8 +660,7 @@ class ApiService {
|
|||||||
'pageNumber': pageNumber.toString(),
|
'pageNumber': pageNumber.toString(),
|
||||||
'pageSize': pageSize.toString(),
|
'pageSize': pageSize.toString(),
|
||||||
'isActive': isActive.toString(),
|
'isActive': isActive.toString(),
|
||||||
if (isArchive)
|
if (isArchive) 'isArchive': 'true',
|
||||||
'isArchive': 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _getRequest(endpoint, queryParams: queryParams);
|
final response = await _getRequest(endpoint, queryParams: queryParams);
|
||||||
|
|||||||
@ -54,44 +54,50 @@ class CustomAppBar extends StatelessWidget
|
|||||||
),
|
),
|
||||||
title: Padding(
|
title: Padding(
|
||||||
padding: MySpacing.only(right: horizontalPadding, left: 8),
|
padding: MySpacing.only(right: horizontalPadding, left: 8),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
MyText.titleLarge(
|
Expanded(
|
||||||
title,
|
child: Column(
|
||||||
fontWeight: 800,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: onPrimaryColor,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
maxLines: 1,
|
children: [
|
||||||
),
|
MyText.titleLarge(
|
||||||
MySpacing.height(3),
|
title,
|
||||||
GetBuilder<ProjectController>(
|
fontWeight: 800,
|
||||||
builder: (projectController) {
|
color: onPrimaryColor,
|
||||||
final displayProjectName = projectName ??
|
overflow: TextOverflow.ellipsis,
|
||||||
projectController.selectedProject?.name ??
|
maxLines: 1,
|
||||||
'Select Project';
|
),
|
||||||
return Row(
|
MySpacing.height(3),
|
||||||
mainAxisSize: MainAxisSize.min,
|
GetBuilder<ProjectController>(
|
||||||
children: [
|
builder: (projectController) {
|
||||||
const Icon(Icons.folder_open,
|
final displayProjectName = projectName ??
|
||||||
size: 14, color: onPrimaryColor),
|
projectController.selectedProject?.name ??
|
||||||
MySpacing.width(4),
|
'Select Project';
|
||||||
Flexible(
|
return Row(
|
||||||
child: MyText.bodySmall(
|
children: [
|
||||||
displayProjectName,
|
const Icon(Icons.folder_open,
|
||||||
fontWeight: 500,
|
size: 14, color: onPrimaryColor),
|
||||||
color: onPrimaryColor.withOpacity(0.8),
|
MySpacing.width(4),
|
||||||
overflow: TextOverflow.ellipsis,
|
Flexible(
|
||||||
),
|
child: MyText.bodySmall(
|
||||||
),
|
displayProjectName,
|
||||||
MySpacing.width(2),
|
fontWeight: 500,
|
||||||
const Icon(Icons.keyboard_arrow_down,
|
color: onPrimaryColor.withOpacity(0.8),
|
||||||
size: 18, color: onPrimaryColor),
|
overflow: TextOverflow.ellipsis,
|
||||||
],
|
maxLines: 1,
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
|
MySpacing.width(2),
|
||||||
|
const Icon(Icons.keyboard_arrow_down,
|
||||||
|
size: 18, color: onPrimaryColor),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -220,8 +220,9 @@ class _AddServiceProjectJobBottomSheetState
|
|||||||
.where((s) => s.isNotEmpty);
|
.where((s) => s.isNotEmpty);
|
||||||
|
|
||||||
for (final p in parts) {
|
for (final p in parts) {
|
||||||
if (!controller.enteredTags.contains(p)) {
|
final clean = p.replaceAll('_', ' ');
|
||||||
controller.enteredTags.add(p);
|
if (!controller.enteredTags.contains(clean)) {
|
||||||
|
controller.enteredTags.add(clean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -239,8 +240,9 @@ class _AddServiceProjectJobBottomSheetState
|
|||||||
.where((s) => s.isNotEmpty);
|
.where((s) => s.isNotEmpty);
|
||||||
|
|
||||||
for (final p in parts) {
|
for (final p in parts) {
|
||||||
if (!controller.enteredTags.contains(p)) {
|
final clean = p.replaceAll('_', ' ');
|
||||||
controller.enteredTags.add(p);
|
if (!controller.enteredTags.contains(clean)) {
|
||||||
|
controller.enteredTags.add(clean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controller.tagCtrl.clear();
|
controller.tagCtrl.clear();
|
||||||
@ -256,8 +258,9 @@ class _AddServiceProjectJobBottomSheetState
|
|||||||
.where((s) => s.isNotEmpty);
|
.where((s) => s.isNotEmpty);
|
||||||
|
|
||||||
for (final p in parts) {
|
for (final p in parts) {
|
||||||
if (!controller.enteredTags.contains(p)) {
|
final clean = p.replaceAll('_', ' ');
|
||||||
controller.enteredTags.add(p);
|
if (!controller.enteredTags.contains(clean)) {
|
||||||
|
controller.enteredTags.add(clean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controller.tagCtrl.clear();
|
controller.tagCtrl.clear();
|
||||||
|
|||||||
@ -324,9 +324,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
? DateFormat('dd MMM yyyy').format(parsedDate)
|
? DateFormat('dd MMM yyyy').format(parsedDate)
|
||||||
: (dateStr.isNotEmpty ? dateStr : '—');
|
: (dateStr.isNotEmpty ? dateStr : '—');
|
||||||
|
|
||||||
final formattedTime =
|
|
||||||
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
|
|
||||||
|
|
||||||
final project = item.name ?? '';
|
final project = item.name ?? '';
|
||||||
final desc = item.title ?? '';
|
final desc = item.title ?? '';
|
||||||
final amount = (item.amount ?? 0).toDouble();
|
final amount = (item.amount ?? 0).toDouble();
|
||||||
@ -356,16 +353,6 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
style:
|
style:
|
||||||
TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
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),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@ -247,6 +247,43 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
|
|
||||||
final tenants = tenantSwitchController.tenants;
|
final tenants = tenantSwitchController.tenants;
|
||||||
if (tenants.isEmpty) return _noTenantContainer();
|
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;
|
final selectedTenant = TenantService.currentTenant;
|
||||||
|
|
||||||
|
|||||||
@ -47,11 +47,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = Get.put(ServiceProjectDetailsController());
|
controller = Get.find<ServiceProjectDetailsController>();
|
||||||
|
// fetch and seed local selected lists
|
||||||
controller.fetchJobDetail(widget.jobId).then((_) {
|
controller.fetchJobDetail(widget.jobId).then((_) {
|
||||||
final job = controller.jobDetail.value?.data;
|
final job = controller.jobDetail.value?.data;
|
||||||
if (job != null) {
|
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 ?? '';
|
_titleController.text = job.title ?? '';
|
||||||
_descriptionController.text = job.description ?? '';
|
_descriptionController.text = job.description ?? '';
|
||||||
_startDateController.text = DateTimeUtils.convertUtcToLocal(
|
_startDateController.text = DateTimeUtils.convertUtcToLocal(
|
||||||
@ -61,7 +63,6 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
job.dueDate ?? '',
|
job.dueDate ?? '',
|
||||||
format: "yyyy-MM-dd");
|
format: "yyyy-MM-dd");
|
||||||
_selectedAssignees.value = job.assignees ?? [];
|
_selectedAssignees.value = job.assignees ?? [];
|
||||||
_selectedTags.value = job.tags ?? [];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,7 +77,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _editJob() async {
|
||||||
|
_processTagsInput();
|
||||||
final job = controller.jobDetail.value?.data;
|
final job = controller.jobDetail.value?.data;
|
||||||
if (job == null) return;
|
if (job == null) return;
|
||||||
|
|
||||||
@ -116,39 +128,56 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalAssignees = job.assignees;
|
// Assignees payload (keep same approach)
|
||||||
final assigneesPayload = originalAssignees?.map((a) {
|
final originalAssignees = job.assignees ?? [];
|
||||||
|
final assigneesPayload = originalAssignees.map((a) {
|
||||||
final isSelected = _selectedAssignees.any((s) => s.id == a.id);
|
final isSelected = _selectedAssignees.any((s) => s.id == a.id);
|
||||||
return {"employeeId": a.id, "isActive": isSelected};
|
return {"employeeId": a.id, "isActive": isSelected};
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
// add newly added assignees
|
||||||
for (var s in _selectedAssignees) {
|
for (var s in _selectedAssignees) {
|
||||||
if (!(originalAssignees?.any((a) => a.id == s.id) ?? false)) {
|
if (!(originalAssignees.any((a) => a.id == s.id))) {
|
||||||
assigneesPayload?.add({"employeeId": s.id, "isActive": true});
|
assigneesPayload.add({"employeeId": s.id, "isActive": true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
operations.add(
|
operations.add(
|
||||||
{"op": "replace", "path": "/assignees", "value": assigneesPayload});
|
{"op": "replace", "path": "/assignees", "value": assigneesPayload});
|
||||||
|
|
||||||
final originalTags = job.tags;
|
// TAGS: build robust payload using original tags and current selection
|
||||||
final replaceTagsPayload = originalTags?.map((t) {
|
final originalTags = job.tags ?? [];
|
||||||
final isSelected = _selectedTags.any((s) => s.id == t.id);
|
final currentTags = _selectedTags.toList();
|
||||||
return {"id": t.id, "name": t.name, "isActive": isSelected};
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final addTagsPayload = _selectedTags
|
// Only add tags operation if something changed
|
||||||
.where((t) => t.id == "0")
|
if (_tagsAreDifferent(originalTags, currentTags)) {
|
||||||
.map((t) => {"name": t.name, "isActive": true})
|
final List<Map<String, dynamic>> finalTagsPayload = [];
|
||||||
.toList();
|
|
||||||
|
|
||||||
if ((replaceTagsPayload?.isNotEmpty ?? false)) {
|
// 1) For existing original tags - we need to mark isActive true/false depending on whether they're in currentTags
|
||||||
operations
|
for (var ot in originalTags) {
|
||||||
.add({"op": "replace", "path": "/tags", "value": replaceTagsPayload});
|
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) {
|
// 2) Add newly created tags from currentTags that don't have a valid id (id == "0" or null)
|
||||||
operations.add({"op": "add", "path": "/tags", "value": addTagsPayload});
|
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) {
|
if (operations.isEmpty) {
|
||||||
@ -169,10 +198,18 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
title: "Success",
|
title: "Success",
|
||||||
message: "Job updated successfully",
|
message: "Job updated successfully",
|
||||||
type: SnackbarType.success);
|
type: SnackbarType.success);
|
||||||
|
|
||||||
|
// re-fetch job detail and update local selected tags from server response
|
||||||
await controller.fetchJobDetail(widget.jobId);
|
await controller.fetchJobDetail(widget.jobId);
|
||||||
final updatedJob = controller.jobDetail.value?.data;
|
final updatedJob = controller.jobDetail.value?.data;
|
||||||
|
|
||||||
if (updatedJob != null) {
|
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;
|
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 {
|
Future<void> _handleTagAction() async {
|
||||||
final job = controller.jobDetail.value?.data;
|
final job = controller.jobDetail.value?.data;
|
||||||
if (job == null) return;
|
if (job == null) return;
|
||||||
@ -408,10 +468,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
border: Border.all(color: Colors.grey.shade400),
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
),
|
),
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text("Tap to select assignees",
|
||||||
"Tap to select assignees",
|
style: TextStyle(fontSize: 14, color: Colors.grey[700])),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -422,19 +480,24 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
Widget _tagEditor() {
|
Widget _tagEditor() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final editing = isEditing.value;
|
final editing = isEditing.value;
|
||||||
final tags = _selectedTags;
|
final job = controller.jobDetail.value?.data;
|
||||||
|
|
||||||
|
final displayTags = editing ? _selectedTags : (job?.tags ?? []);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
children: tags
|
children: displayTags
|
||||||
.map(
|
.map(
|
||||||
(t) => Chip(
|
(t) => Chip(
|
||||||
label: Text(t.name ?? ''),
|
label: Text(t.name ?? ''),
|
||||||
onDeleted: editing
|
onDeleted: editing
|
||||||
? () {
|
? () {
|
||||||
_selectedTags.remove(t);
|
_selectedTags.removeWhere((x) =>
|
||||||
|
(x.id != null && x.id == t.id) ||
|
||||||
|
(x.name == t.name));
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@ -445,17 +508,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
if (editing)
|
if (editing)
|
||||||
TextField(
|
TextField(
|
||||||
controller: _tagTextController,
|
controller: _tagTextController,
|
||||||
onSubmitted: (v) {
|
onChanged: (value) {
|
||||||
final value = v.trim();
|
// If space or comma typed → process tags immediately
|
||||||
if (value.isNotEmpty && !tags.any((t) => t.name == value)) {
|
if (value.endsWith(" ") || value.contains(",")) {
|
||||||
_selectedTags.add(Tag(id: "0", name: value));
|
_processTagsInput();
|
||||||
}
|
}
|
||||||
_tagTextController.clear();
|
},
|
||||||
|
onSubmitted: (_) {
|
||||||
|
// Still supports ENTER
|
||||||
|
_processTagsInput();
|
||||||
},
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "Type and press enter to add tags",
|
hintText: "Type tags (space or comma to add multiple tags)",
|
||||||
border:
|
border: OutlineInputBorder(
|
||||||
OutlineInputBorder(borderRadius: BorderRadius.circular(5)),
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||||
@ -495,11 +562,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
Obx(() => IconButton(
|
Obx(() => IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isAttendanceExpanded.value
|
isAttendanceExpanded.value
|
||||||
? Icons.expand_less
|
? Icons.expand_less
|
||||||
: Icons.expand_more,
|
: Icons.expand_more,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600]),
|
||||||
),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
isAttendanceExpanded.value =
|
isAttendanceExpanded.value =
|
||||||
!isAttendanceExpanded.value;
|
!isAttendanceExpanded.value;
|
||||||
@ -526,22 +592,17 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
height: 16,
|
height: 16,
|
||||||
width: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: Colors.white, strokeWidth: 2))
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(action == 0 ? Icons.login : Icons.logout),
|
: Icon(action == 0 ? Icons.login : Icons.logout),
|
||||||
label: MyText.bodyMedium(
|
label: MyText.bodyMedium(
|
||||||
action == 0 ? "Tag In" : "Tag Out",
|
action == 0 ? "Tag In" : "Tag Out",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: Colors.white,
|
color: Colors.white),
|
||||||
),
|
|
||||||
onPressed: isLoading ? null : _handleTagAction,
|
onPressed: isLoading ? null : _handleTagAction,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5)),
|
||||||
),
|
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
action == 0 ? Colors.green : Colors.red,
|
action == 0 ? Colors.green : Colors.red,
|
||||||
),
|
),
|
||||||
@ -563,10 +624,8 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
if (logs.isEmpty) {
|
if (logs.isEmpty) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: MyText.bodyMedium(
|
child: MyText.bodyMedium("No attendance logs available",
|
||||||
"No attendance logs available",
|
color: Colors.grey[600]),
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -601,25 +660,21 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
log.action == 0 ? Icons.login : Icons.logout,
|
log.action == 0
|
||||||
color: log.action == 0
|
? Icons.login
|
||||||
? Colors.green
|
: Icons.logout,
|
||||||
: Colors.red,
|
color: log.action == 0
|
||||||
size: 18,
|
? Colors.green
|
||||||
),
|
: Colors.red,
|
||||||
|
size: 18),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(employeeName,
|
||||||
employeeName,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.w600))),
|
||||||
fontWeight: FontWeight.w600),
|
Text("$date | $time",
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontSize: 12, color: Colors.grey[700])),
|
||||||
Text(
|
|
||||||
"$date | $time",
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@ -627,12 +682,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
// Comment
|
// Comment
|
||||||
if (log.comment?.isNotEmpty == true)
|
if (log.comment?.isNotEmpty == true)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(log.comment!,
|
||||||
log.comment!,
|
style: const TextStyle(fontSize: 13))),
|
||||||
style: const TextStyle(fontSize: 13),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
if (log.latitude != null && log.longitude != null)
|
if (log.latitude != null && log.longitude != null)
|
||||||
@ -657,14 +709,12 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
Icon(Icons.location_on,
|
Icon(Icons.location_on,
|
||||||
size: 14, color: Colors.blue),
|
size: 14, color: Colors.blue),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
Text(
|
Text("View Location",
|
||||||
"View Location",
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 12,
|
||||||
fontSize: 12,
|
color: Colors.blue,
|
||||||
color: Colors.blue,
|
decoration:
|
||||||
decoration:
|
TextDecoration.underline)),
|
||||||
TextDecoration.underline),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -679,16 +729,13 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (_) => Dialog(
|
builder: (_) => Dialog(
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
log.document!.preSignedUrl,
|
log.document!.preSignedUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
height: 250,
|
height: 250,
|
||||||
errorBuilder: (_, __, ___) =>
|
errorBuilder: (_, __, ___) =>
|
||||||
const Icon(
|
const Icon(Icons.broken_image,
|
||||||
Icons.broken_image,
|
size: 50,
|
||||||
size: 50,
|
color: Colors.grey)),
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
@ -701,10 +748,9 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
width: 50,
|
width: 50,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => const Icon(
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
Icons.broken_image,
|
Icons.broken_image,
|
||||||
size: 40,
|
size: 40,
|
||||||
color: Colors.grey,
|
color: Colors.grey),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -728,14 +774,10 @@ class _JobDetailsScreenState extends State<JobDetailsScreen> with UIMixin {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: MyText.bodySmall(label,
|
child: MyText.bodySmall(label,
|
||||||
fontWeight: 600, color: Colors.grey.shade700),
|
fontWeight: 600, color: Colors.grey.shade700)),
|
||||||
),
|
Expanded(flex: 5, child: MyText.bodyMedium(value, fontWeight: 500)),
|
||||||
Expanded(
|
|
||||||
flex: 5,
|
|
||||||
child: MyText.bodyMedium(value, fontWeight: 500),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -912,12 +954,11 @@ class JobTimeline extends StatelessWidget {
|
|||||||
isFirst: index == 0,
|
isFirst: index == 0,
|
||||||
isLast: index == reversedLogs.length - 1,
|
isLast: index == reversedLogs.length - 1,
|
||||||
indicatorStyle: const IndicatorStyle(
|
indicatorStyle: const IndicatorStyle(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
indicator: DecoratedBox(
|
indicator: DecoratedBox(
|
||||||
decoration:
|
decoration: BoxDecoration(
|
||||||
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(
|
endChild: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@ -932,13 +973,12 @@ class JobTimeline extends StatelessWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4)),
|
borderRadius: BorderRadius.circular(4)),
|
||||||
child: MyText.bodySmall(initials, fontWeight: 600),
|
child: MyText.bodySmall(initials, fontWeight: 600)),
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Expanded(child: MyText.bodySmall(updatedBy)),
|
Expanded(child: MyText.bodySmall(updatedBy)),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user