diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index d3fdb92..0c549c3 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -10,7 +10,7 @@ class AddContactController extends GetxController { final RxList tags = [].obs; final RxString selectedCategory = ''.obs; - final RxString selectedBucket = ''.obs; + final RxList selectedBuckets = [].obs; final RxString selectedProject = ''.obs; final RxList enteredTags = [].obs; @@ -50,7 +50,7 @@ class AddContactController extends GetxController { void resetForm() { selectedCategory.value = ''; selectedProject.value = ''; - selectedBucket.value = ''; + selectedBuckets.clear(); enteredTags.clear(); filteredSuggestions.clear(); filteredOrgSuggestions.clear(); @@ -100,7 +100,21 @@ class AddContactController extends GetxController { isSubmitting.value = true; final categoryId = categoriesMap[selectedCategory.value]; - final bucketId = bucketsMap[selectedBucket.value]; + final bucketIds = selectedBuckets + .map((name) => bucketsMap[name]) + .whereType() + .toList(); + + if (bucketIds.isEmpty) { + showAppSnackbar( + title: "Missing Buckets", + message: "Please select at least one bucket.", + type: SnackbarType.warning, + ); + isSubmitting.value = false; + return; + } + final projectIds = selectedProjects .map((name) => projectsMap[name]) .whereType() @@ -126,10 +140,10 @@ class AddContactController extends GetxController { return; } - if (selectedBucket.value.trim().isEmpty || bucketId == null) { + if (selectedBuckets.isEmpty) { showAppSnackbar( title: "Missing Bucket", - message: "Please select a bucket.", + message: "Please select at least one bucket.", type: SnackbarType.warning, ); isSubmitting.value = false; @@ -151,7 +165,7 @@ class AddContactController extends GetxController { if (selectedCategory.value.isNotEmpty && categoryId != null) "contactCategoryId": categoryId, if (projectIds.isNotEmpty) "projectIds": projectIds, - "bucketIds": [bucketId], + "bucketIds": bucketIds, if (enteredTags.isNotEmpty) "tags": tagObjects, if (emails.isNotEmpty) "contactEmails": emails, if (phones.isNotEmpty) "contactPhones": phones, diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 1cdcbc4..bef19f1 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -32,16 +32,20 @@ class DirectoryController extends GetxController { // -------------------- COMMENTS HANDLING -------------------- - RxList getCommentsForContact(String contactId, {bool active = true}) { + RxList getCommentsForContact(String contactId, + {bool active = true}) { return active ? activeCommentsMap[contactId] ?? [].obs : inactiveCommentsMap[contactId] ?? [].obs; } - Future fetchCommentsForContact(String contactId, {bool active = true}) async { + Future fetchCommentsForContact(String contactId, + {bool active = true}) async { try { - final data = await ApiService.getDirectoryComments(contactId, active: active); - var comments = data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; + final data = + await ApiService.getDirectoryComments(contactId, active: active); + var comments = + data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; // ✅ Deduplicate by ID before storing final Map uniqueMap = { @@ -51,12 +55,15 @@ class DirectoryController extends GetxController { ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); if (active) { - activeCommentsMap[contactId] = [].obs..assignAll(comments); + activeCommentsMap[contactId] = [].obs + ..assignAll(comments); } else { - inactiveCommentsMap[contactId] = [].obs..assignAll(comments); + inactiveCommentsMap[contactId] = [].obs + ..assignAll(comments); } } catch (e, stack) { - logSafe("Error fetching ${active ? 'active' : 'inactive'} comments: $e", level: LogLevel.error); + logSafe("Error fetching ${active ? 'active' : 'inactive'} comments: $e", + level: LogLevel.error); logSafe(stack.toString(), level: LogLevel.debug); if (active) { @@ -99,8 +106,8 @@ class DirectoryController extends GetxController { return; } - final success = - await ApiService.updateContactComment(comment.id, comment.note, comment.contactId); + final success = await ApiService.updateContactComment( + comment.id, comment.note, comment.contactId); if (success) { await fetchCommentsForContact(comment.contactId, active: true); @@ -206,6 +213,67 @@ class DirectoryController extends GetxController { logSafe("Bucket fetch error: $e", level: LogLevel.error); } } +// -------------------- CONTACT DELETION / RESTORE -------------------- + + Future deleteContact(String contactId) async { + try { + final success = await ApiService.deleteDirectoryContact(contactId); + if (success) { + // Refresh contacts after deletion + await fetchContacts(active: true); + await fetchContacts(active: false); + showAppSnackbar( + title: "Deleted", + message: "Contact deleted successfully.", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to delete contact.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Delete contact failed: $e", level: LogLevel.error); + logSafe(stack.toString(), level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "Something went wrong while deleting contact.", + type: SnackbarType.error, + ); + } + } + + Future restoreContact(String contactId) async { + try { + final success = await ApiService.restoreDirectoryContact(contactId); + if (success) { + // Refresh contacts after restore + await fetchContacts(active: true); + await fetchContacts(active: false); + showAppSnackbar( + title: "Restored", + message: "Contact restored successfully.", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to restore contact.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Restore contact failed: $e", level: LogLevel.error); + logSafe(stack.toString(), level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "Something went wrong while restoring contact.", + type: SnackbarType.error, + ); + } + } Future fetchContacts({bool active = true}) async { try { @@ -282,7 +350,8 @@ class DirectoryController extends GetxController { return categoryMatch && bucketMatch && searchMatch; }).toList(); - filteredContacts.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + filteredContacts + .sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } void toggleCategory(String categoryId) { diff --git a/lib/controller/task_planning/daily_task_planning_controller.dart b/lib/controller/task_planning/daily_task_planning_controller.dart index 0eda9d7..3b02beb 100644 --- a/lib/controller/task_planning/daily_task_planning_controller.dart +++ b/lib/controller/task_planning/daily_task_planning_controller.dart @@ -9,11 +9,11 @@ import 'package:marco/model/employees/employee_model.dart'; class DailyTaskPlanningController extends GetxController { List projects = []; - List employees = []; - List dailyTasks = []; - - RxMap uploadingStates = {}.obs; + RxList employees = [].obs; RxList selectedEmployees = [].obs; + List allEmployeesCache = []; + List dailyTasks = []; + RxMap uploadingStates = {}.obs; MyFormValidator basicValidator = MyFormValidator(); List> roles = []; @@ -27,17 +27,10 @@ class DailyTaskPlanningController extends GetxController { void onInit() { super.onInit(); fetchRoles(); - _initializeDefaults(); - } - - void _initializeDefaults() { - fetchProjects(); } String? formFieldValidator(String? value, {required String fieldType}) { - if (value == null || value.trim().isEmpty) { - return 'This field is required'; - } + if (value == null || value.trim().isEmpty) return 'This field is required'; if (fieldType == "target" && int.tryParse(value.trim()) == null) { return 'Please enter a valid number'; } @@ -48,9 +41,8 @@ class DailyTaskPlanningController extends GetxController { } void updateSelectedEmployees() { - final selected = + selectedEmployees.value = employees.where((e) => uploadingStates[e.id]?.value == true).toList(); - selectedEmployees.value = selected; logSafe("Updated selected employees", level: LogLevel.debug); } @@ -77,6 +69,8 @@ class DailyTaskPlanningController extends GetxController { required String description, required List taskTeam, DateTime? assignmentDate, + String? organizationId, + String? serviceId, }) async { isAssigningTask.value = true; logSafe("Starting assign task...", level: LogLevel.info); @@ -87,6 +81,8 @@ class DailyTaskPlanningController extends GetxController { description: description, taskTeam: taskTeam, assignmentDate: assignmentDate, + organizationId: organizationId, + serviceId: serviceId, ); isAssigningTask.value = false; @@ -110,70 +106,42 @@ class DailyTaskPlanningController extends GetxController { } } - Future fetchProjects() async { - isFetchingProjects.value = true; - try { - final response = await ApiService.getProjects(); - if (response?.isEmpty ?? true) { - logSafe("No project data found or API call failed", - level: LogLevel.warning); - return; - } - - projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); - logSafe("Projects fetched: ${projects.length} projects loaded", - level: LogLevel.info); - update(); - } catch (e, stack) { - logSafe("Error fetching projects", - level: LogLevel.error, error: e, stackTrace: stack); - } finally { - isFetchingProjects.value = false; - } - } - /// Fetch Infra details and then tasks per work area Future fetchTaskData(String? projectId, {String? serviceId}) async { - if (projectId == null) { - logSafe("Project ID is null", level: LogLevel.warning); - return; - } + if (projectId == null) return; isFetchingTasks.value = true; try { - // Fetch infra details - final infraResponse = await ApiService.getInfraDetails(projectId); + final infraResponse = await ApiService.getInfraDetails( + projectId, + serviceId: serviceId, + ); final infraData = infraResponse?['data'] as List?; if (infraData == null || infraData.isEmpty) { - logSafe("No infra data found for project $projectId", - level: LogLevel.warning); dailyTasks = []; return; } - // Map infra to dailyTasks structure dailyTasks = infraData.map((buildingJson) { final building = Building( id: buildingJson['id'], name: buildingJson['buildingName'], description: buildingJson['description'], - floors: (buildingJson['floors'] as List).map((floorJson) { - return Floor( - id: floorJson['id'], - floorName: floorJson['floorName'], - workAreas: - (floorJson['workAreas'] as List).map((areaJson) { - return WorkArea( - id: areaJson['id'], - areaName: areaJson['areaName'], - workItems: [], // Will fill after tasks API - ); - }).toList(), - ); - }).toList(), + floors: (buildingJson['floors'] as List) + .map((floorJson) => Floor( + id: floorJson['id'], + floorName: floorJson['floorName'], + workAreas: (floorJson['workAreas'] as List) + .map((areaJson) => WorkArea( + id: areaJson['id'], + areaName: areaJson['areaName'], + workItems: [], + )) + .toList(), + )) + .toList(), ); - return TaskPlanningDetailsModel( id: building.id, name: building.name, @@ -186,50 +154,39 @@ class DailyTaskPlanningController extends GetxController { ); }).toList(); - // Fetch tasks for each work area, passing serviceId only if selected await Future.wait(dailyTasks .expand((task) => task.buildings) .expand((b) => b.floors) .expand((f) => f.workAreas) .map((area) async { try { - final taskResponse = await ApiService.getWorkItemsByWorkArea( - area.id, - // serviceId: serviceId, // <-- only pass if not null - ); + final taskResponse = + await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId); final taskData = taskResponse?['data'] as List? ?? []; - - area.workItems.addAll(taskData.map((taskJson) { - return WorkItemWrapper( - workItemId: taskJson['id'], - workItem: WorkItem( - id: taskJson['id'], - activityMaster: taskJson['activityMaster'] != null - ? ActivityMaster.fromJson(taskJson['activityMaster']) - : null, - workCategoryMaster: taskJson['workCategoryMaster'] != null - ? WorkCategoryMaster.fromJson( - taskJson['workCategoryMaster']) - : null, - plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(), - completedWork: (taskJson['completedWork'] as num?)?.toDouble(), - todaysAssigned: - (taskJson['todaysAssigned'] as num?)?.toDouble(), - description: taskJson['description'] as String?, - taskDate: taskJson['taskDate'] != null - ? DateTime.tryParse(taskJson['taskDate']) - : null, - ), - ); - })); + area.workItems.addAll(taskData.map((taskJson) => WorkItemWrapper( + workItemId: taskJson['id'], + workItem: WorkItem( + id: taskJson['id'], + activityMaster: taskJson['activityMaster'] != null + ? ActivityMaster.fromJson(taskJson['activityMaster']) + : null, + workCategoryMaster: taskJson['workCategoryMaster'] != null + ? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster']) + : null, + plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(), + completedWork: (taskJson['completedWork'] as num?)?.toDouble(), + todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(), + description: taskJson['description'] as String?, + taskDate: taskJson['taskDate'] != null + ? DateTime.tryParse(taskJson['taskDate']) + : null, + ), + ))); } catch (e, stack) { logSafe("Error fetching tasks for work area ${area.id}", level: LogLevel.error, error: e, stackTrace: stack); } })); - - logSafe("Fetched infra and tasks for project $projectId", - level: LogLevel.info); } catch (e, stack) { logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack); @@ -239,42 +196,68 @@ class DailyTaskPlanningController extends GetxController { } } - Future fetchEmployeesByProject(String? projectId) async { - if (projectId == null || projectId.isEmpty) { - logSafe("Project ID is required but was null or empty", - level: LogLevel.error); - return; - } + Future fetchEmployeesByProjectService({ + required String projectId, + String? serviceId, + String? organizationId, + }) async { + isFetchingEmployees.value = true; - isFetchingEmployees.value = true; try { - final response = await ApiService.getAllEmployeesByProject(projectId); + final response = await ApiService.getEmployeesByProjectService( + projectId, + serviceId: serviceId ?? '', + organizationId: organizationId ?? '', + ); + if (response != null && response.isNotEmpty) { - employees = - response.map((json) => EmployeeModel.fromJson(json)).toList(); - for (var emp in employees) { - uploadingStates[emp.id] = false.obs; + employees.assignAll(response.map((json) => EmployeeModel.fromJson(json))); + + if (serviceId == null && organizationId == null) { + allEmployeesCache = List.from(employees); } - logSafe( - "Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, - ); + + final currentEmployeeIds = employees.map((e) => e.id).toSet(); + + uploadingStates.removeWhere((key, _) => !currentEmployeeIds.contains(key)); + employees.forEach((emp) { + uploadingStates.putIfAbsent(emp.id, () => false.obs); + }); + + selectedEmployees.removeWhere((e) => !currentEmployeeIds.contains(e.id)); + + logSafe("Employees fetched: ${employees.length}", level: LogLevel.info); } else { - employees = []; + employees.clear(); + uploadingStates.clear(); + selectedEmployees.clear(); logSafe( - "No employees found for project $projectId", + serviceId != null || organizationId != null + ? "Filtered employees empty" + : "No employees found", level: LogLevel.warning, ); } } catch (e, stack) { - logSafe( - "Error fetching employees for project $projectId", - level: LogLevel.error, - error: e, - stackTrace: stack, - ); + logSafe("Error fetching employees", level: LogLevel.error, error: e, stackTrace: stack); + + if (serviceId == null && organizationId == null && allEmployeesCache.isNotEmpty) { + employees.assignAll(allEmployeesCache); + + final cachedEmployeeIds = employees.map((e) => e.id).toSet(); + uploadingStates.removeWhere((key, _) => !cachedEmployeeIds.contains(key)); + employees.forEach((emp) { + uploadingStates.putIfAbsent(emp.id, () => false.obs); + }); + + selectedEmployees.removeWhere((e) => !cachedEmployeeIds.contains(e.id)); + } else { + employees.clear(); + uploadingStates.clear(); + selectedEmployees.clear(); + } } finally { - isFetchingEmployees.value = false; + isFetchingEmployees.value = false; update(); } } diff --git a/lib/controller/tenant/all_organization_controller.dart b/lib/controller/tenant/all_organization_controller.dart new file mode 100644 index 0000000..75ce2fd --- /dev/null +++ b/lib/controller/tenant/all_organization_controller.dart @@ -0,0 +1,66 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/all_organization_model.dart'; + +class AllOrganizationController extends GetxController { + RxList organizations = [].obs; + Rxn selectedOrganization = Rxn(); + final isLoadingOrganizations = false.obs; + + String? passedOrgId; + + AllOrganizationController({this.passedOrgId}); + + @override + void onInit() { + super.onInit(); + fetchAllOrganizations(); + } + + Future fetchAllOrganizations() async { + try { + isLoadingOrganizations.value = true; + + final response = await ApiService.getAllOrganizations(); + if (response != null && response.data.data.isNotEmpty) { + organizations.value = response.data.data; + + // Select organization based on passed ID, or fallback to first + if (passedOrgId != null) { + selectedOrganization.value = + organizations.firstWhere( + (org) => org.id == passedOrgId, + orElse: () => organizations.first, + ); + } else { + selectedOrganization.value ??= organizations.first; + } + } else { + organizations.clear(); + selectedOrganization.value = null; + } + } catch (e, stackTrace) { + logSafe( + "Failed to fetch organizations: $e", + level: LogLevel.error, + error: e, + stackTrace: stackTrace, + ); + organizations.clear(); + selectedOrganization.value = null; + } finally { + isLoadingOrganizations.value = false; + } + } + + void selectOrganization(AllOrganization? org) { + selectedOrganization.value = org; + } + + void clearSelection() { + selectedOrganization.value = null; + } + + String get currentSelection => selectedOrganization.value?.name ?? "All Organizations"; +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 437b8b5..321312e 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -22,6 +22,7 @@ class ApiEndpoints { // Employee Screen API Endpoints static const String getAllEmployeesByProject = "/employee/list"; + static const String getAllEmployeesByOrganization = "/project/get/task/team"; static const String getAllEmployees = "/employee/list"; static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getRoles = "/roles/jobrole"; @@ -53,6 +54,8 @@ class ApiEndpoints { static const String getDirectoryOrganization = "/directory/organization"; static const String createContact = "/directory"; static const String updateContact = "/directory"; + static const String deleteContact = "/directory"; + static const String restoreContact = "/directory/note"; static const String getDirectoryNotes = "/directory/notes"; static const String updateDirectoryNotes = "/directory/note"; static const String createBucket = "/directory/bucket"; @@ -94,5 +97,7 @@ class ApiEndpoints { static const String getAssignedOrganizations = "/project/get/assigned/organization"; + static const getAllOrganizations = "/organization/list"; + static const String getAssignedServices = "/Project/get/assigned/services"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index ec0578e..8b20011 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -22,6 +22,7 @@ import 'package:marco/model/attendance/organization_per_project_list_model.dart' import 'package:marco/model/tenant/tenant_services_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart'; import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart'; +import 'package:marco/model/all_organization_model.dart'; class ApiService { static const bool enableLogs = true; @@ -321,6 +322,32 @@ class ApiService { return null; } + static Future getAllOrganizations() async { + final endpoint = "${ApiEndpoints.getAllOrganizations}"; + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("All Organizations request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "All Organizations"); + + if (jsonResponse != null) { + return AllOrganizationListResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getAllOrganizations: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + //// Get Services assigned to a Project static Future getAssignedServices( String projectId) async { @@ -1786,6 +1813,52 @@ class ApiService { return data is List ? data : null; } + /// Deletes a directory contact (sets active=false) + static Future deleteDirectoryContact(String contactId) async { + final endpoint = "${ApiEndpoints.updateContact}/$contactId/"; + final queryParams = {'active': 'false'}; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + _log("Deleting directory contact at $uri"); + + final response = await _deleteRequest( + "$endpoint?active=false", + ); + + if (response != null && response.statusCode == 200) { + _log("Contact deleted successfully: ${response.body}"); + return true; + } + + _log("Failed to delete contact: ${response?.body}"); + return false; + } + + /// Restores a directory contact (sets active=true) + static Future restoreDirectoryContact(String contactId) async { + final endpoint = "${ApiEndpoints.updateContact}/$contactId/"; + final queryParams = {'active': 'true'}; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint") + .replace(queryParameters: queryParams); + + _log("Restoring directory contact at $uri"); + + final response = await _deleteRequest( + "$endpoint?active=true", + ); + + if (response != null && response.statusCode == 200) { + _log("Contact restored successfully: ${response.body}"); + return true; + } + + _log("Failed to restore contact: ${response?.body}"); + return false; + } + static Future updateContact( String contactId, Map payload) async { try { @@ -2056,6 +2129,36 @@ class ApiService { ); } + /// Fetches employees by projectId, serviceId, and organizationId + static Future?> getEmployeesByProjectService( + String projectId, { + String? serviceId, + String? organizationId, + }) async { + if (projectId.isEmpty) { + throw ArgumentError('projectId must not be empty'); + } + + // Construct query parameters only if non-empty + final queryParams = {}; + if (serviceId != null && serviceId.isNotEmpty) { + queryParams['serviceId'] = serviceId; + } + if (organizationId != null && organizationId.isNotEmpty) { + queryParams['organizationId'] = organizationId; + } + + final endpoint = "${ApiEndpoints.getAllEmployeesByOrganization}/$projectId"; + + final response = await _getRequest(endpoint, queryParams: queryParams); + + if (response != null) { + return _parseResponse(response, label: 'Employees by Project Service'); + } else { + return null; + } + } + static Future?> getAllEmployees( {String? organizationId}) async { var endpoint = ApiEndpoints.getAllEmployees; @@ -2159,7 +2262,7 @@ class ApiService { static Future?> getDailyTasks( String projectId, { - Map? filter, // <-- New: combined filter + Map? filter, int pageNumber = 1, int pageSize = 20, }) async { @@ -2238,9 +2341,14 @@ class ApiService { return response.statusCode == 200 && json['success'] == true; } - /// Fetch infra details for a given project - static Future?> getInfraDetails(String projectId) async { - final endpoint = "/project/infra-details/$projectId"; + /// Fetch infra details for a project, optionally filtered by service + static Future?> getInfraDetails(String projectId, + {String? serviceId}) async { + String endpoint = "/project/infra-details/$projectId"; + + if (serviceId != null && serviceId.isNotEmpty) { + endpoint += "?serviceId=$serviceId"; + } final res = await _getRequest(endpoint); if (res == null) { @@ -2253,10 +2361,14 @@ class ApiService { as Map?; } - /// Fetch work items for a given work area - static Future?> getWorkItemsByWorkArea( - String workAreaId) async { - final endpoint = "/project/tasks/$workAreaId"; + /// Fetch work items for a given work area, optionally filtered by service + static Future?> getWorkItemsByWorkArea(String workAreaId, + {String? serviceId}) async { + String endpoint = "/project/tasks/$workAreaId"; + + if (serviceId != null && serviceId.isNotEmpty) { + endpoint += "?serviceId=$serviceId"; + } final res = await _getRequest(endpoint); if (res == null) { @@ -2275,12 +2387,16 @@ class ApiService { required String description, required List taskTeam, DateTime? assignmentDate, + String? organizationId, + String? serviceId, }) async { final body = { "workItemId": workItemId, "plannedTask": plannedTask, "description": description, "taskTeam": taskTeam, + "organizationId": organizationId, + "serviceId": serviceId, "assignmentDate": (assignmentDate ?? DateTime.now()).toUtc().toIso8601String(), }; diff --git a/lib/helpers/widgets/my_snackbar.dart b/lib/helpers/widgets/my_snackbar.dart index cc57819..d30ee77 100644 --- a/lib/helpers/widgets/my_snackbar.dart +++ b/lib/helpers/widgets/my_snackbar.dart @@ -38,7 +38,7 @@ void showAppSnackbar({ snackPosition: SnackPosition.BOTTOM, margin: const EdgeInsets.all(16), borderRadius: 8, - duration: const Duration(seconds: 3), + duration: const Duration(minutes: 1), icon: Icon( iconData, color: Colors.white, diff --git a/lib/helpers/widgets/tenant/all_organization_selector.dart b/lib/helpers/widgets/tenant/all_organization_selector.dart new file mode 100644 index 0000000..8a1fc67 --- /dev/null +++ b/lib/helpers/widgets/tenant/all_organization_selector.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/tenant/all_organization_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/all_organization_model.dart'; + +class AllOrganizationListView extends StatelessWidget { + final AllOrganizationController controller; + + /// Optional callback when an organization is tapped + final void Function(AllOrganization)? onTapOrganization; + + const AllOrganizationListView({ + super.key, + required this.controller, + this.onTapOrganization, + }); + + Widget _loadingPlaceholder() { + return ListView.separated( + itemCount: 5, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 150, + height: 14, + color: Colors.grey.shade400, + ), + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.grey.shade400, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoadingOrganizations.value) { + return _loadingPlaceholder(); + } + + if (controller.organizations.isEmpty) { + return Center( + child: MyText.bodyMedium( + "No organizations found", + color: Colors.grey, + ), + ); + } + + return ListView.separated( + itemCount: controller.organizations.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final org = controller.organizations[index]; + return ListTile( + title: Text(org.name), + onTap: () { + if (onTapOrganization != null) { + onTapOrganization!(org); + } + }, + ); + }, + ); + }); + } +} diff --git a/lib/model/all_organization_model.dart b/lib/model/all_organization_model.dart new file mode 100644 index 0000000..80e70cf --- /dev/null +++ b/lib/model/all_organization_model.dart @@ -0,0 +1,184 @@ +class AllOrganizationListResponse { + final bool success; + final String message; + final OrganizationData data; + final dynamic errors; + final int statusCode; + final String timestamp; + + AllOrganizationListResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory AllOrganizationListResponse.fromJson(Map json) { + return AllOrganizationListResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? OrganizationData.fromJson(json['data']) + : OrganizationData(currentPage: 0, totalPages: 0, totalEntities: 0, data: []), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; + } +} + +class OrganizationData { + final int currentPage; + final int totalPages; + final int totalEntities; + final List data; + + OrganizationData({ + required this.currentPage, + required this.totalPages, + required this.totalEntities, + required this.data, + }); + + factory OrganizationData.fromJson(Map json) { + return OrganizationData( + currentPage: json['currentPage'] ?? 0, + totalPages: json['totalPages'] ?? 0, + totalEntities: json['totalEntities'] ?? 0, + data: (json['data'] as List?) + ?.map((e) => AllOrganization.fromJson(e)) + .toList() ?? + [], + ); + } + + Map toJson() { + return { + 'currentPage': currentPage, + 'totalPages': totalPages, + 'totalEntities': totalEntities, + 'data': data.map((e) => e.toJson()).toList(), + }; + } +} + +class AllOrganization { + final String id; + final String name; + final String email; + final String contactPerson; + final String address; + final String contactNumber; + final int sprid; + final String? logoImage; + final String createdAt; + final User? createdBy; + final User? updatedBy; + final String? updatedAt; + final bool isActive; + + AllOrganization({ + required this.id, + required this.name, + required this.email, + required this.contactPerson, + required this.address, + required this.contactNumber, + required this.sprid, + this.logoImage, + required this.createdAt, + this.createdBy, + this.updatedBy, + this.updatedAt, + required this.isActive, + }); + + factory AllOrganization.fromJson(Map json) { + return AllOrganization( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', + contactPerson: json['contactPerson'] ?? '', + address: json['address'] ?? '', + contactNumber: json['contactNumber'] ?? '', + sprid: json['sprid'] ?? 0, + logoImage: json['logoImage'], + createdAt: json['createdAt'] ?? '', + createdBy: json['createdBy'] != null ? User.fromJson(json['createdBy']) : null, + updatedBy: json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, + updatedAt: json['updatedAt'], + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'contactPerson': contactPerson, + 'address': address, + 'contactNumber': contactNumber, + 'sprid': sprid, + 'logoImage': logoImage, + 'createdAt': createdAt, + 'createdBy': createdBy?.toJson(), + 'updatedBy': updatedBy?.toJson(), + 'updatedAt': updatedAt, + 'isActive': isActive, + }; + } +} + +class User { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String jobRoleName; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'] ?? '', + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; + } +} diff --git a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart index 2bf9d88..4db2228 100644 --- a/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlanning/assign_task_bottom_sheet .dart @@ -2,10 +2,16 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_Planning/daily_task_Planning_controller.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/tenant/organization_selection_controller.dart'; +import 'package:marco/controller/tenant/service_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/tenant/organization_selector.dart'; +import 'package:marco/helpers/widgets/tenant/service_selector.dart'; +import 'package:marco/model/attendance/organization_per_project_list_model.dart'; +import 'package:marco/model/tenant/tenant_services_model.dart'; class AssignTaskBottomSheet extends StatefulWidget { final String workLocation; @@ -36,24 +42,46 @@ class AssignTaskBottomSheet extends StatefulWidget { class _AssignTaskBottomSheetState extends State { final DailyTaskPlanningController controller = Get.find(); final ProjectController projectController = Get.find(); + + final OrganizationController orgController = Get.put(OrganizationController()); + final ServiceController serviceController = Get.put(ServiceController()); + final TextEditingController targetController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); final ScrollController _employeeListScrollController = ScrollController(); String? selectedProjectId; + Organization? selectedOrganization; + Service? selectedService; @override void initState() { super.initState(); selectedProjectId = projectController.selectedProjectId.value; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (selectedProjectId != null) { - controller.fetchEmployeesByProject(selectedProjectId!); + await orgController.fetchOrganizations(selectedProjectId!); + _resetSelections(); + await _fetchEmployeesAndTasks(); } }); } + void _resetSelections() { + controller.selectedEmployees.clear(); + controller.uploadingStates.forEach((key, value) => value.value = false); + } + + Future _fetchEmployeesAndTasks() async { + await controller.fetchEmployeesByProjectService( + projectId: selectedProjectId!, + serviceId: selectedService?.id, + organizationId: selectedOrganization?.id, + ); + await controller.fetchTaskData(selectedProjectId, serviceId: selectedService?.id); + } + @override void dispose() { _employeeListScrollController.dispose(); @@ -77,12 +105,47 @@ class _AssignTaskBottomSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _infoRow(Icons.location_on, "Work Location", - "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), - Divider(), - _infoRow(Icons.pending_actions, "Pending Task of Activity", - "${widget.pendingTask}"), - Divider(), + // Organization Selector + SizedBox( + height: 50, + child: OrganizationSelector( + controller: orgController, + onSelectionChanged: (org) async { + setState(() => selectedOrganization = org); + _resetSelections(); + if (selectedProjectId != null) await _fetchEmployeesAndTasks(); + }, + ), + ), + MySpacing.height(12), + + // Service Selector + SizedBox( + height: 50, + child: ServiceSelector( + controller: serviceController, + onSelectionChanged: (service) async { + setState(() => selectedService = service); + _resetSelections(); + if (selectedProjectId != null) await _fetchEmployeesAndTasks(); + }, + ), + ), + MySpacing.height(16), + + // Work Location Info + _infoRow( + Icons.location_on, + "Work Location", + "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}", + ), + const Divider(), + + // Pending Task Info + _infoRow(Icons.pending_actions, "Pending Task", "${widget.pendingTask}"), + const Divider(), + + // Role Selector GestureDetector( onTap: _onRoleMenuPressed, child: Row( @@ -94,21 +157,34 @@ class _AssignTaskBottomSheetState extends State { ), ), MySpacing.height(8), + + // Employee List Container( - constraints: const BoxConstraints(maxHeight: 150), + constraints: const BoxConstraints(maxHeight: 180), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), child: _buildEmployeeList(), ), MySpacing.height(8), + + // Selected Employees Chips _buildSelectedEmployees(), + MySpacing.height(8), + + // Target Input _buildTextField( icon: Icons.track_changes, label: "Target for Today :", controller: targetController, hintText: "Enter target", - keyboardType: TextInputType.number, + keyboardType: const TextInputType.numberWithOptions(decimal: true), validatorType: "target", ), - MySpacing.height(24), + MySpacing.height(16), + + // Description Input _buildTextField( icon: Icons.description, label: "Description :", @@ -122,8 +198,7 @@ class _AssignTaskBottomSheetState extends State { } void _onRoleMenuPressed() { - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; + final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final Size screenSize = overlay.size; showMenu( @@ -144,56 +219,24 @@ class _AssignTaskBottomSheetState extends State { }), ], ).then((value) { - if (value != null) { - controller.onRoleSelected(value == 'all' ? null : value); - } + if (value != null) controller.onRoleSelected(value == 'all' ? null : value); }); } Widget _buildEmployeeList() { return Obx(() { - if (controller.isFetchingTasks.value) { - // Skeleton loader instead of CircularProgressIndicator - return ListView.separated( - shrinkWrap: true, - itemCount: 5, // show 5 skeleton rows - separatorBuilder: (_, __) => const SizedBox(height: 4), - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - child: Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Container( - height: 14, - color: Colors.grey.shade300, - ), - ), - ], - ), - ); - }, - ); + if (controller.isFetchingEmployees.value) { + return Center(child: CircularProgressIndicator()); } - final selectedRoleId = controller.selectedRoleId.value; - final filteredEmployees = selectedRoleId == null + final filteredEmployees = controller.selectedRoleId.value == null ? controller.employees : controller.employees - .where((e) => e.jobRoleID.toString() == selectedRoleId) + .where((e) => e.jobRoleID.toString() == controller.selectedRoleId.value) .toList(); if (filteredEmployees.isEmpty) { - return const Text("No employees found for selected role."); + return Center(child: Text("No employees available for selected role.")); } return Scrollbar( @@ -201,43 +244,32 @@ class _AssignTaskBottomSheetState extends State { thumbVisibility: true, child: ListView.builder( controller: _employeeListScrollController, - shrinkWrap: true, itemCount: filteredEmployees.length, itemBuilder: (context, index) { final employee = filteredEmployees[index]; final rxBool = controller.uploadingStates[employee.id]; - return Obx(() => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Checkbox( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - value: rxBool?.value ?? false, - onChanged: (bool? selected) { - if (rxBool != null) { - rxBool.value = selected ?? false; - controller.updateSelectedEmployees(); - } - }, - fillColor: - WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const Color.fromARGB(255, 95, 132, 255); - } - return Colors.transparent; - }), - checkColor: Colors.white, - side: const BorderSide(color: Colors.black), - ), - const SizedBox(width: 8), - Expanded( - child: Text(employee.name, - style: const TextStyle(fontSize: 14))), - ], + return Obx(() => ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + leading: Checkbox( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + value: rxBool?.value ?? false, + onChanged: (selected) { + if (rxBool != null) { + rxBool.value = selected ?? false; + controller.updateSelectedEmployees(); + } + }, + fillColor: MaterialStateProperty.resolveWith((states) => + states.contains(MaterialState.selected) + ? const Color.fromARGB(255, 95, 132, 255) + : Colors.transparent), + checkColor: Colors.white, + side: const BorderSide(color: Colors.black), ), + title: Text(employee.name, style: const TextStyle(fontSize: 14)), + visualDensity: VisualDensity.compact, )); }, ), @@ -249,30 +281,25 @@ class _AssignTaskBottomSheetState extends State { return Obx(() { if (controller.selectedEmployees.isEmpty) return Container(); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: controller.selectedEmployees.map((e) { - return Obx(() { - final isSelected = - controller.uploadingStates[e.id]?.value ?? false; - if (!isSelected) return Container(); + return Wrap( + spacing: 4, + runSpacing: 4, + children: controller.selectedEmployees.map((e) { + return Obx(() { + final isSelected = controller.uploadingStates[e.id]?.value ?? false; + if (!isSelected) return Container(); - return Chip( - label: - Text(e.name, style: const TextStyle(color: Colors.white)), - backgroundColor: const Color.fromARGB(255, 95, 132, 255), - deleteIcon: const Icon(Icons.close, color: Colors.white), - onDeleted: () { - controller.uploadingStates[e.id]?.value = false; - controller.updateSelectedEmployees(); - }, - ); - }); - }).toList(), - ), + return Chip( + label: Text(e.name, style: const TextStyle(color: Colors.white)), + backgroundColor: const Color.fromARGB(255, 95, 132, 255), + deleteIcon: const Icon(Icons.close, color: Colors.white), + onDeleted: () { + controller.uploadingStates[e.id]?.value = false; + controller.updateSelectedEmployees(); + }, + ); + }); + }).toList(), ); }); } @@ -289,25 +316,22 @@ class _AssignTaskBottomSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon(icon, size: 18, color: Colors.black54), - const SizedBox(width: 6), - MyText.titleMedium(label, fontWeight: 600), - ], - ), + Row(children: [ + Icon(icon, size: 18, color: Colors.black54), + const SizedBox(width: 6), + MyText.titleMedium(label, fontWeight: 600), + ]), MySpacing.height(6), TextFormField( controller: controller, keyboardType: keyboardType, maxLines: maxLines, - decoration: const InputDecoration( - hintText: '', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), - validator: (value) => this - .controller - .formFieldValidator(value, fieldType: validatorType), + validator: (value) => this.controller.formFieldValidator(value, fieldType: validatorType), ), ], ); @@ -326,13 +350,9 @@ class _AssignTaskBottomSheetState extends State { text: TextSpan( children: [ WidgetSpan( - child: MyText.titleMedium("$title: ", - fontWeight: 600, color: Colors.black), - ), - TextSpan( - text: value, - style: const TextStyle(color: Colors.black), + child: MyText.titleMedium("$title: ", fontWeight: 600, color: Colors.black), ), + TextSpan(text: value, style: const TextStyle(color: Colors.black)), ], ), ), @@ -349,29 +369,20 @@ class _AssignTaskBottomSheetState extends State { .toList(); if (selectedTeam.isEmpty) { - showAppSnackbar( - title: "Team Required", - message: "Please select at least one team member", - type: SnackbarType.error, - ); + showAppSnackbar(title: "Team Required", message: "Please select at least one team member", type: SnackbarType.error); return; } - final target = int.tryParse(targetController.text.trim()); + final target = double.tryParse(targetController.text.trim()); if (target == null || target <= 0) { - showAppSnackbar( - title: "Invalid Input", - message: "Please enter a valid target number", - type: SnackbarType.error, - ); + showAppSnackbar(title: "Invalid Input", message: "Please enter a valid target number", type: SnackbarType.error); return; } if (target > widget.pendingTask) { showAppSnackbar( title: "Target Too High", - message: - "Target cannot be greater than pending task (${widget.pendingTask})", + message: "Target cannot exceed pending task (${widget.pendingTask})", type: SnackbarType.error, ); return; @@ -379,20 +390,18 @@ class _AssignTaskBottomSheetState extends State { final description = descriptionController.text.trim(); if (description.isEmpty) { - showAppSnackbar( - title: "Description Required", - message: "Please enter a description", - type: SnackbarType.error, - ); + showAppSnackbar(title: "Description Required", message: "Please enter a description", type: SnackbarType.error); return; } controller.assignDailyTask( workItemId: widget.workItemId, - plannedTask: target, + plannedTask: target.toInt(), description: description, taskTeam: selectedTeam, assignmentDate: widget.assignmentDate, + organizationId: selectedOrganization?.id, + serviceId: selectedService?.id, ); } } diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 924b09d..f0f456d 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -74,12 +74,20 @@ class _AddContactBottomSheetState extends State { ever(controller.isInitialized, (bool ready) { if (ready) { + // Buckets - map all + if (c.bucketIds.isNotEmpty) { + final names = c.bucketIds + .map((id) { + return controller.bucketsMap.entries + .firstWhereOrNull((e) => e.value == id) + ?.key; + }) + .whereType() + .toList(); + controller.selectedBuckets.assignAll(names); + } + // Projects and Category mapping - as before final projectIds = c.projectIds; - final bucketId = c.bucketIds.firstOrNull; - final category = c.contactCategory?.name; - - if (category != null) controller.selectedCategory.value = category; - if (projectIds != null) { controller.selectedProjects.assignAll( projectIds @@ -90,16 +98,12 @@ class _AddContactBottomSheetState extends State { .toList(), ); } - - if (bucketId != null) { - final name = controller.bucketsMap.entries - .firstWhereOrNull((e) => e.value == bucketId) - ?.key; - if (name != null) controller.selectedBucket.value = name; - } + final category = c.contactCategory?.name; + if (category != null) controller.selectedCategory.value = category; } }); } else { + showAdvanced.value = false; // Optional emailCtrls.add(TextEditingController()); emailLabels.add('Office'.obs); phoneCtrls.add(TextEditingController()); @@ -363,10 +367,129 @@ class _AddContactBottomSheetState extends State { ); } + Widget _bucketMultiSelectField() { + return _multiSelectField( + items: controller.buckets + .map((name) => FilterItem(id: name, name: name)) + .toList(), + fallback: "Choose Buckets", + selectedValues: controller.selectedBuckets, + ); + } + + Widget _multiSelectField({ + required List items, + required String fallback, + required RxList selectedValues, + }) { + if (items.isEmpty) return const SizedBox.shrink(); + + return Obx(() { + final selectedNames = items + .where((f) => selectedValues.contains(f.id)) + .map((f) => f.name) + .join(", "); + final displayText = selectedNames.isNotEmpty ? selectedNames : fallback; + + return Builder( + builder: (context) { + return GestureDetector( + onTap: () async { + final RenderBox button = context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero); + + await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0, + ), + items: [ + PopupMenuItem( + enabled: false, + child: StatefulBuilder( + builder: (context, setState) { + return SizedBox( + width: 250, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: items.map((f) { + final isChecked = selectedValues.contains(f.id); + return CheckboxListTile( + dense: true, + title: Text(f.name), + value: isChecked, + contentPadding: EdgeInsets.zero, + controlAffinity: + ListTileControlAffinity.leading, + side: const BorderSide( + color: Colors.black, width: 1.5), + fillColor: + MaterialStateProperty.resolveWith( + (states) { + if (states + .contains(MaterialState.selected)) { + return Colors.indigo; // selected color + } + return Colors + .white; // unselected background + }), + checkColor: Colors.white, // tick color + onChanged: (val) { + if (val == true) { + selectedValues.add(f.id); + } else { + selectedValues.remove(f.id); + } + setState(() {}); + }, + ); + }).toList(), + ), + ), + ); + }, + ), + ), + ], + ); + }, + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText( + displayText, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + }, + ); + }); + } + void _handleSubmit() { bool valid = formKey.currentState?.validate() ?? false; - if (controller.selectedBucket.value.isEmpty) { + if (controller.selectedBuckets.isEmpty) { bucketError.value = "Bucket is required"; valid = false; } else { @@ -430,29 +553,14 @@ class _AddContactBottomSheetState extends State { MySpacing.height(16), _textField("Organization", orgCtrl, required: true), MySpacing.height(16), - _labelWithStar("Bucket", required: true), + _labelWithStar("Buckets", required: true), MySpacing.height(8), Stack( children: [ - _popupSelector(controller.selectedBucket, controller.buckets, - "Choose Bucket"), - Positioned( - left: 0, - right: 0, - top: 56, - child: Obx(() => bucketError.value.isEmpty - ? const SizedBox.shrink() - : Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: Text(bucketError.value, - style: const TextStyle( - color: Colors.red, fontSize: 12)), - )), - ), + _bucketMultiSelectField(), ], ), - MySpacing.height(24), + MySpacing.height(12), Obx(() => GestureDetector( onTap: () => showAdvanced.toggle(), child: Row( @@ -562,3 +670,9 @@ class _AddContactBottomSheetState extends State { }); } } + +class FilterItem { + final String id; + final String name; + FilterItem({required this.id, required this.name}); +} diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index 434224d..3cd4a4e 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -5,7 +5,7 @@ import 'package:intl/intl.dart'; import 'package:marco/controller/employee/add_employee_controller.dart'; import 'package:marco/controller/employee/employees_screen_controller.dart'; -import 'package:marco/controller/tenant/organization_selection_controller.dart'; +import 'package:marco/controller/tenant/all_organization_controller.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; @@ -24,8 +24,7 @@ class AddEmployeeBottomSheet extends StatefulWidget { class _AddEmployeeBottomSheetState extends State with UIMixin { late final AddEmployeeController _controller; - final OrganizationController _organizationController = - Get.put(OrganizationController()); + late final AllOrganizationController _organizationController; // Local UI state bool _hasApplicationAccess = false; @@ -39,52 +38,62 @@ class _AddEmployeeBottomSheetState extends State @override void initState() { super.initState(); + + // Initialize text controllers _orgFieldController = TextEditingController(); _joiningDateController = TextEditingController(); _genderController = TextEditingController(); _roleController = TextEditingController(); + // Initialize AddEmployeeController _controller = Get.put(AddEmployeeController(), tag: UniqueKey().toString()); + // Pass organization ID from employeeData if available + final orgIdFromEmployee = + widget.employeeData?['organization_id'] as String?; + _organizationController = Get.put( + AllOrganizationController(passedOrgId: orgIdFromEmployee), + tag: UniqueKey().toString(), + ); + + // Keep _orgFieldController in sync with selected organization safely + ever(_organizationController.selectedOrganization, (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _orgFieldController.text = + _organizationController.selectedOrganization.value?.name ?? + 'All Organizations'; + }); + }); + + // Prefill other fields if editing if (widget.employeeData != null) { _controller.editingEmployeeData = widget.employeeData; _controller.prefillFields(); + // Application access _hasApplicationAccess = widget.employeeData?['hasApplicationAccess'] ?? false; + // Email final email = widget.employeeData?['email']; if (email != null && email.toString().isNotEmpty) { _controller.basicValidator.getController('email')?.text = email.toString(); } - final orgId = widget.employeeData?['organization_id']; - if (orgId != null) { - final org = _organizationController.organizations - .firstWhereOrNull((o) => o.id == orgId); - if (org != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _organizationController.selectOrganization(org); - _controller.selectedOrganizationId = org.id; - _orgFieldController.text = org.name; - }); - } - } - - // ✅ Prefill Joining date + // Joining date if (_controller.joiningDate != null) { _joiningDateController.text = DateFormat('dd MMM yyyy').format(_controller.joiningDate!); } - // ✅ Prefill Gender + // Gender if (_controller.selectedGender != null) { _genderController.text = _controller.selectedGender!.name.capitalizeFirst ?? ''; } - // ✅ Prefill Role + // Prefill Role _controller.fetchRoles().then((_) { if (_controller.selectedRoleId != null) { final roleName = _controller.roles.firstWhereOrNull( @@ -97,6 +106,7 @@ class _AddEmployeeBottomSheetState extends State } }); } else { + // Not editing: fetch roles _controller.fetchRoles(); } } @@ -151,27 +161,37 @@ class _AddEmployeeBottomSheetState extends State MySpacing.height(16), _sectionLabel('Organization'), MySpacing.height(8), - GestureDetector( - onTap: () => _showOrganizationPopup(context), - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: _orgFieldController, - validator: (val) { - if (val == null || - val.trim().isEmpty || - val == 'All Organizations') { - return 'Organization is required'; - } - return null; - }, - decoration: - _inputDecoration('Select Organization').copyWith( - suffixIcon: const Icon(Icons.expand_more), + Obx(() { + return GestureDetector( + onTap: () => _showOrganizationPopup(context), + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: _orgFieldController, + validator: (val) { + if (val == null || + val.trim().isEmpty || + val == 'All Organizations') { + return 'Organization is required'; + } + return null; + }, + decoration: + _inputDecoration('Select Organization').copyWith( + suffixIcon: _organizationController + .isLoadingOrganizations.value + ? const SizedBox( + width: 24, + height: 24, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + ), ), ), - ), - ), + ); + }), MySpacing.height(24), _sectionLabel('Application Access'), Row( @@ -479,7 +499,7 @@ class _AddEmployeeBottomSheetState extends State context: context, initialDate: _controller.joiningDate ?? DateTime.now(), firstDate: DateTime(2000), - lastDate: DateTime.now(), + lastDate: DateTime.now(), ); if (picked != null) { @@ -493,12 +513,13 @@ class _AddEmployeeBottomSheetState extends State final isValid = _controller.basicValidator.formKey.currentState?.validate() ?? false; + final selectedOrg = _organizationController.selectedOrganization.value; + if (!isValid || _controller.joiningDate == null || _controller.selectedGender == null || _controller.selectedRoleId == null || - _organizationController.currentSelection.isEmpty || - _organizationController.currentSelection == 'All Organizations') { + selectedOrg == null) { showAppSnackbar( title: 'Missing Fields', message: 'Please complete all required fields.', @@ -507,6 +528,8 @@ class _AddEmployeeBottomSheetState extends State return; } + _controller.selectedOrganizationId = selectedOrg.id; + final result = await _controller.createOrUpdateEmployee( email: _controller.basicValidator.getController('email')?.text.trim(), hasApplicationAccess: _hasApplicationAccess, @@ -539,7 +562,7 @@ class _AddEmployeeBottomSheetState extends State return; } - final selected = await showMenu( + final selectedOrgId = await showMenu( context: context, position: _popupMenuPosition(context), items: orgs @@ -552,12 +575,10 @@ class _AddEmployeeBottomSheetState extends State .toList(), ); - if (selected != null && selected.trim().isNotEmpty) { - final chosen = orgs.firstWhere((e) => e.id == selected); - _organizationController.selectOrganization(chosen); - _controller.selectedOrganizationId = chosen.id; - _orgFieldController.text = chosen.name; - _controller.update(); + if (selectedOrgId != null) { + final chosenOrg = orgs.firstWhere((org) => org.id == selectedOrgId, + orElse: () => orgs.first); + _organizationController.selectOrganization(chosenOrg); } } diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index d1e6647..7817c3a 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -17,6 +17,7 @@ import 'package:marco/view/directory/contact_detail_screen.dart'; import 'package:marco/view/directory/manage_bucket_screen.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; class DirectoryView extends StatefulWidget { @override @@ -89,7 +90,7 @@ class _DirectoryViewState extends State { padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(5), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -114,7 +115,7 @@ class _DirectoryViewState extends State { backgroundColor: Colors.grey[300], foregroundColor: Colors.black, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -129,7 +130,7 @@ class _DirectoryViewState extends State { backgroundColor: Colors.indigo, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -179,6 +180,7 @@ class _DirectoryViewState extends State { ), body: Column( children: [ + // Search + Filter + More menu Padding( padding: MySpacing.xy(8, 8), child: Row( @@ -200,9 +202,8 @@ class _DirectoryViewState extends State { suffixIcon: ValueListenableBuilder( valueListenable: searchController, builder: (context, value, _) { - if (value.text.isEmpty) { + if (value.text.isEmpty) return const SizedBox.shrink(); - } return IconButton( icon: const Icon(Icons.clear, size: 20, color: Colors.grey), @@ -254,7 +255,7 @@ class _DirectoryViewState extends State { isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( - top: Radius.circular(20)), + top: Radius.circular(5)), ), builder: (_) => const DirectoryFilterBottomSheet(), @@ -292,8 +293,7 @@ class _DirectoryViewState extends State { icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), + borderRadius: BorderRadius.circular(5)), itemBuilder: (context) { List> menuItems = []; @@ -302,17 +302,13 @@ class _DirectoryViewState extends State { const PopupMenuItem( enabled: false, height: 30, - child: Text( - "Actions", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), + child: Text("Actions", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey)), ), ); - // ✅ Conditionally show Create Bucket option if (permissionController .hasPermission(Permissions.directoryAdmin) || permissionController @@ -378,13 +374,10 @@ class _DirectoryViewState extends State { const PopupMenuItem( enabled: false, height: 30, - child: Text( - "Preferences", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), + child: Text("Preferences", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey)), ), ); @@ -398,7 +391,8 @@ class _DirectoryViewState extends State { const Icon(Icons.visibility_off_outlined, size: 20, color: Colors.black87), const SizedBox(width: 10), - const Expanded(child: Text('Show Deleted Contacts')), + const Expanded( + child: Text('Show Deleted Contacts')), Switch.adaptive( value: !controller.isActive.value, activeColor: Colors.indigo, @@ -420,211 +414,347 @@ class _DirectoryViewState extends State { ], ), ), + // Contact List Expanded( child: Obx(() { return MyRefreshIndicator( - onRefresh: _refreshDirectory, - backgroundColor: Colors.indigo, - color: Colors.white, - child: controller.isLoading.value - ? ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: 10, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, __) => - SkeletonLoaders.contactSkeletonCard(), - ) - : controller.filteredContacts.isEmpty - ? _buildEmptyState() - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: MySpacing.only( - left: 8, right: 8, top: 4, bottom: 80), - itemCount: controller.filteredContacts.length, - separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (_, index) { - final contact = - controller.filteredContacts[index]; - final nameParts = contact.name.trim().split(" "); - final firstName = nameParts.first; - final lastName = - nameParts.length > 1 ? nameParts.last : ""; - final tags = - contact.tags.map((tag) => tag.name).toList(); + onRefresh: _refreshDirectory, + backgroundColor: Colors.indigo, + color: Colors.white, + child: controller.isLoading.value + ? ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: 10, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, __) => + SkeletonLoaders.contactSkeletonCard(), + ) + : controller.filteredContacts.isEmpty + ? _buildEmptyState() + : ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: MySpacing.only( + left: 8, right: 8, top: 4, bottom: 80), + itemCount: controller.filteredContacts.length, + separatorBuilder: (_, __) => MySpacing.height(12), + itemBuilder: (_, index) { + final contact = + controller.filteredContacts[index]; + final isDeleted = !controller + .isActive.value; // mark deleted contacts + final nameParts = + contact.name.trim().split(" "); + final firstName = nameParts.first; + final lastName = + nameParts.length > 1 ? nameParts.last : ""; + final tags = contact.tags + .map((tag) => tag.name) + .toList(); - return InkWell( - onTap: () { - Get.to(() => - ContactDetailScreen(contact: contact)); - }, - child: Padding( - padding: - const EdgeInsets.fromLTRB(12, 10, 12, 0), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Avatar( - firstName: firstName, - lastName: lastName, - size: 35), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.titleSmall(contact.name, - fontWeight: 600, - overflow: - TextOverflow.ellipsis), - MyText.bodySmall( - contact.organization, - color: Colors.grey[700], - overflow: - TextOverflow.ellipsis), - MySpacing.height(8), - if (contact - .contactEmails.isNotEmpty) - GestureDetector( - onTap: () => - LauncherUtils.launchEmail( - contact - .contactEmails - .first - .emailAddress), - onLongPress: () => LauncherUtils - .copyToClipboard( - contact.contactEmails.first - .emailAddress, - typeLabel: 'Email', + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + elevation: 3, + shadowColor: Colors.grey.withOpacity(0.3), + color: Colors.white, + child: InkWell( + borderRadius: BorderRadius.circular(5), + onTap: isDeleted + ? null + : () => Get.to(() => + ContactDetailScreen( + contact: contact)), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // Avatar + Avatar( + firstName: firstName, + lastName: lastName, + size: 40, + backgroundColor: isDeleted + ? Colors.grey.shade400 + : null, + ), + MySpacing.width(12), + // Contact Info + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + contact.name, + fontWeight: 600, + overflow: + TextOverflow.ellipsis, + color: isDeleted + ? Colors.grey + : Colors.black87, ), - child: Padding( - padding: - const EdgeInsets.only( - bottom: 4), - child: Row( - children: [ - const Icon( - Icons.email_outlined, - size: 16, - color: Colors.indigo), - MySpacing.width(4), - Expanded( - child: - MyText.labelSmall( - contact - .contactEmails - .first - .emailAddress, - overflow: TextOverflow - .ellipsis, - color: Colors.indigo, - decoration: - TextDecoration - .underline, - ), - ), - ], - ), + MyText.bodySmall( + contact.organization, + color: isDeleted + ? Colors.grey + : Colors.grey[700], + overflow: + TextOverflow.ellipsis, ), - ), - if (contact - .contactPhones.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: 8, top: 4), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => LauncherUtils - .launchPhone(contact - .contactPhones - .first - .phoneNumber), - onLongPress: () => - LauncherUtils - .copyToClipboard( - contact - .contactPhones - .first - .phoneNumber, - typeLabel: 'Phone', - ), - child: Row( - children: [ - const Icon( - Icons - .phone_outlined, - size: 16, - color: Colors - .indigo), - MySpacing.width(4), - Expanded( - child: MyText - .labelSmall( + MySpacing.height(6), + if (contact + .contactEmails.isNotEmpty) + Padding( + padding: + const EdgeInsets.only( + bottom: 4), + child: GestureDetector( + onTap: isDeleted + ? null + : () => LauncherUtils + .launchEmail(contact + .contactEmails + .first + .emailAddress), + onLongPress: isDeleted + ? null + : () => LauncherUtils + .copyToClipboard( contact - .contactPhones + .contactEmails .first - .phoneNumber, - overflow: - TextOverflow - .ellipsis, - color: Colors - .indigo, - decoration: - TextDecoration - .underline, + .emailAddress, + typeLabel: + 'Email', + ), + child: Row( + children: [ + Icon( + Icons + .email_outlined, + size: 16, + color: isDeleted + ? Colors.grey + : Colors + .indigo), + MySpacing.width(4), + Expanded( + child: MyText + .labelSmall( + contact + .contactEmails + .first + .emailAddress, + overflow: + TextOverflow + .ellipsis, + color: isDeleted + ? Colors.grey + : Colors + .indigo, + decoration: + TextDecoration + .underline, + ), + ), + ], + ), + ), + ), + if (contact + .contactPhones.isNotEmpty) + Padding( + padding: + const EdgeInsets.only( + bottom: 8, top: 4), + child: Row( + children: [ + Expanded( + child: + GestureDetector( + onTap: isDeleted + ? null + : () => LauncherUtils + .launchPhone(contact + .contactPhones + .first + .phoneNumber), + onLongPress: + isDeleted + ? null + : () => + LauncherUtils + .copyToClipboard( + contact + .contactPhones + .first + .phoneNumber, + typeLabel: + 'Phone', + ), + child: Row( + children: [ + Icon( + Icons + .phone_outlined, + size: 16, + color: isDeleted + ? Colors + .grey + : Colors + .indigo), + MySpacing.width( + 4), + Expanded( + child: MyText + .labelSmall( + contact + .contactPhones + .first + .phoneNumber, + overflow: + TextOverflow + .ellipsis, + color: isDeleted + ? Colors + .grey + : Colors + .indigo, + decoration: + TextDecoration + .underline, + ), + ), + ], + ), + ), + ), + MySpacing.width(8), + GestureDetector( + onTap: isDeleted + ? null + : () => LauncherUtils + .launchWhatsApp(contact + .contactPhones + .first + .phoneNumber), + child: FaIcon( + FontAwesomeIcons + .whatsapp, + color: isDeleted + ? Colors.grey + : Colors + .green, + size: 25), + ), + ], + ), + ), + if (tags.isNotEmpty) + Padding( + padding: + const EdgeInsets.only( + top: 0), + child: Wrap( + spacing: 6, + runSpacing: 2, + children: tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: + Colors.indigo + .shade50, + labelStyle: TextStyle( + color: isDeleted + ? Colors + .grey + : Colors + .indigo, + fontSize: 12), + visualDensity: + VisualDensity + .compact, + shape: + RoundedRectangleBorder( + borderRadius: + BorderRadius + .circular( + 5), ), ), - ], - ), - ), + ) + .toList(), ), - MySpacing.width(8), - GestureDetector( - onTap: () => LauncherUtils - .launchWhatsApp( - contact - .contactPhones - .first - .phoneNumber), - child: const FaIcon( - FontAwesomeIcons - .whatsapp, - color: Colors.green, - size: 25, - ), - ), - ], + ), + ], + ), + ), + // Actions Column (Arrow + Icons) + Column( + children: [ + IconButton( + icon: Icon( + isDeleted + ? Icons.restore + : Icons.delete, + color: isDeleted + ? Colors.green + : Colors.redAccent, + size: 20, ), + onPressed: () async { + await Get.dialog( + ConfirmDialog( + title: isDeleted + ? "Restore Contact" + : "Delete Contact", + message: isDeleted + ? "Are you sure you want to restore this contact?" + : "Are you sure you want to delete this contact?", + confirmText: isDeleted + ? "Restore" + : "Delete", + confirmColor: isDeleted + ? Colors.green + : Colors.redAccent, + icon: isDeleted + ? Icons.restore + : Icons + .delete_forever, + onConfirm: () async { + if (isDeleted) { + await controller + .restoreContact( + contact.id); + } else { + await controller + .deleteContact( + contact.id); + } + }, + ), + barrierDismissible: false, + ); + }, ), - if (tags.isNotEmpty) ...[ - MySpacing.height(2), - MyText.labelSmall(tags.join(', '), - color: Colors.grey[500], - maxLines: 1, - overflow: - TextOverflow.ellipsis), + const SizedBox(height: 4), + Icon( + Icons.arrow_forward_ios, + color: Colors.grey, + size: 20, + ) ], - ], - ), - ), - Column( - children: [ - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - MySpacing.height(8), + ), ], ), - ], + ), ), - ), - ); - }, - ), - ); + ); + })); }), ) ], diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index 501bab2..fa65804 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -3,8 +3,8 @@ import 'package:get/get.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart'; import 'package:flutter_html/flutter_html.dart' as html; -import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/controller/directory/notes_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -68,7 +68,6 @@ class NotesView extends StatelessWidget { } if (inList) buffer.write(''); - return buffer.toString(); } @@ -98,7 +97,7 @@ class NotesView extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - /// 🔍 Search + Refresh (Top Row) + /// 🔍 Search Field Padding( padding: MySpacing.xy(8, 8), child: Row( @@ -132,7 +131,7 @@ class NotesView extends StatelessWidget { ), ), - /// 📄 Notes List View + /// 📄 Notes List Expanded( child: Obx(() { if (controller.isLoading.value) { @@ -151,9 +150,7 @@ class NotesView extends StatelessWidget { child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: _buildEmptyState(), - ), + child: Center(child: _buildEmptyState()), ), ); }, @@ -204,56 +201,166 @@ class NotesView extends StatelessWidget { duration: const Duration(milliseconds: 250), padding: MySpacing.xy(12, 12), decoration: BoxDecoration( - color: isEditing ? Colors.indigo[50] : Colors.white, + color: isEditing + ? Colors.indigo[50] + : note.isActive + ? Colors.white + : Colors.grey.shade100, border: Border.all( - color: - isEditing ? Colors.indigo : Colors.grey.shade300, + color: note.isActive + ? (isEditing + ? Colors.indigo + : Colors.grey.shade300) + : Colors.grey.shade400, width: 1.1, ), borderRadius: BorderRadius.circular(5), boxShadow: const [ BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2)), + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2), + ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// Header Row - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: initials, lastName: '', size: 40), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall( - "${note.contactName} (${note.organizationName})", - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.indigo[800], + // Header & Note content (fade them if inactive) + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: note.isActive ? 1.0 : 0.6, + child: IgnorePointer( + ignoring: !note.isActive, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Avatar( + firstName: initials, + lastName: '', + size: 40, + backgroundColor: note.isActive + ? null + : Colors.grey.shade400, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.titleSmall( + "${note.contactName} (${note.organizationName})", + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: note.isActive + ? Colors.indigo[800] + : Colors.grey, + ), + MyText.bodySmall( + "by ${note.createdBy.firstName} • $createdDate, $createdTime", + color: note.isActive + ? Colors.grey[600] + : Colors.grey, + ), + ], + ), + ), + ], + ), + MySpacing.height(12), + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () => + controller.editingNoteId.value = null, + onSave: (quillCtrl) async { + final delta = + quillCtrl.document.toDelta(); + final htmlOutput = + _convertDeltaToHtml(delta); + final updated = + note.copyWith(note: htmlOutput); + await controller.updateNote(updated); + controller.editingNoteId.value = null; + }, + ) + else + html.Html( + data: note.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: note.isActive + ? Colors.black87 + : Colors.grey, + ), + }, ), - MyText.bodySmall( - "by ${note.createdBy.firstName} • $createdDate, $createdTime", - color: Colors.grey[600], - ), - ], - ), + ], ), + ), + ), - /// Edit / Delete / Restore Icons + // Action buttons (always fully visible) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (note.isActive) ...[ + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + color: Colors.indigo, + size: 20, + ), + padding: EdgeInsets.all(2), + constraints: const BoxConstraints(), + onPressed: () { + controller.editingNoteId.value = + isEditing ? null : note.id; + }, + ), + IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + size: 20, + ), + constraints: const BoxConstraints(), + onPressed: () async { + await Get.dialog( + ConfirmDialog( + title: "Delete Note", + message: + "Are you sure you want to delete this note?", + confirmText: "Delete", + confirmColor: Colors.redAccent, + icon: Icons.delete_forever, + onConfirm: () async { + await controller.restoreOrDeleteNote( + note, + restore: false); + }, + ), + barrierDismissible: false, + ); + }, + ), + ], if (!note.isActive) IconButton( - icon: const Icon(Icons.restore, - color: Colors.green, size: 20), + icon: const Icon( + Icons.restore, + color: Colors.green, + size: 22, + ), tooltip: "Restore", - padding: EdgeInsets - .zero, onPressed: () async { await Get.dialog( ConfirmDialog( @@ -272,87 +379,9 @@ class NotesView extends StatelessWidget { barrierDismissible: false, ); }, - ) - else - Row( - mainAxisSize: MainAxisSize.min, - children: [ - /// Edit Icon - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - color: Colors.indigo, - size: 20, - ), - padding: EdgeInsets - .zero, - constraints: - const BoxConstraints(), - onPressed: () { - controller.editingNoteId.value = - isEditing ? null : note.id; - }, - ), - const SizedBox( - width: 6), - /// Delete Icon - IconButton( - icon: const Icon(Icons.delete_outline, - color: Colors.redAccent, size: 20), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () async { - await Get.dialog( - ConfirmDialog( - title: "Delete Note", - message: - "Are you sure you want to delete this note?", - confirmText: "Delete", - confirmColor: Colors.redAccent, - icon: Icons.delete_forever, - onConfirm: () async { - await controller - .restoreOrDeleteNote(note, - restore: false); - }, - ), - barrierDismissible: false, - ); - }, - ), - ], ), ], ), - - MySpacing.height(12), - - /// Content - if (isEditing && quillController != null) - CommentEditorCard( - controller: quillController, - onCancel: () => - controller.editingNoteId.value = null, - onSave: (quillCtrl) async { - final delta = quillCtrl.document.toDelta(); - final htmlOutput = _convertDeltaToHtml(delta); - final updated = note.copyWith(note: htmlOutput); - await controller.updateNote(updated); - controller.editingNoteId.value = null; - }, - ) - else - html.Html( - data: note.note, - style: { - "body": html.Style( - margin: html.Margins.zero, - padding: html.HtmlPaddings.zero, - fontSize: html.FontSize.medium, - color: Colors.black87, - ), - }, - ), ], ), ); diff --git a/lib/view/taskPlanning/daily_task_planning.dart b/lib/view/taskPlanning/daily_task_planning.dart index 8e0cfea..f0fa572 100644 --- a/lib/view/taskPlanning/daily_task_planning.dart +++ b/lib/view/taskPlanning/daily_task_planning.dart @@ -40,9 +40,10 @@ class _DailyTaskPlanningScreenState extends State final projectId = projectController.selectedProjectId.value; if (projectId.isNotEmpty) { dailyTaskPlanningController.fetchTaskData(projectId); - serviceController.fetchServices(projectId); // <-- Fetch services here + serviceController.fetchServices(projectId); } + // Whenever project changes, fetch tasks & services ever( projectController.selectedProjectId, (newProjectId) { @@ -122,18 +123,19 @@ class _DailyTaskPlanningScreenState extends State final projectId = projectController.selectedProjectId.value; if (projectId.isNotEmpty) { try { - await dailyTaskPlanningController.fetchTaskData(projectId); + await dailyTaskPlanningController.fetchTaskData( + projectId, + serviceId: serviceController.selectedService?.id, + ); } catch (e) { debugPrint('Error refreshing task data: ${e.toString()}'); } } }, child: SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), // <-- always allow drag + physics: const AlwaysScrollableScrollPhysics(), padding: MySpacing.x(0), child: ConstrainedBox( - // <-- ensures full screen height constraints: BoxConstraints( minHeight: MediaQuery.of(context).size.height - kToolbarHeight - @@ -158,8 +160,8 @@ class _DailyTaskPlanningScreenState extends State if (projectId.isNotEmpty) { await dailyTaskPlanningController.fetchTaskData( projectId, - // serviceId: service - // ?.id, + serviceId: + service?.id, // <-- pass selected service ); } }, @@ -287,7 +289,6 @@ class _DailyTaskPlanningScreenState extends State final validWorkAreas = floor.workAreas .where((area) => area.workItems.isNotEmpty); - // For each valid work area, return a Floor+WorkArea ExpansionTile return validWorkAreas.map((area) { final floorWorkAreaKey = "${buildingKey}_${floor.floorName}_${area.areaName}"; @@ -301,6 +302,7 @@ class _DailyTaskPlanningScreenState extends State final totalProgress = totalPlanned == 0 ? 0.0 : (totalCompleted / totalPlanned).clamp(0.0, 1.0); + return ExpansionTile( onExpansionChanged: (expanded) { setMainState(() { @@ -352,7 +354,7 @@ class _DailyTaskPlanningScreenState extends State percent: totalProgress, center: Text( "${(totalProgress * 100).toStringAsFixed(0)}%", - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -438,7 +440,7 @@ class _DailyTaskPlanningScreenState extends State permissionController.hasPermission( Permissions.assignReportTask)) IconButton( - icon: Icon( + icon: const Icon( Icons.person_add_alt_1_rounded, color: Color.fromARGB(255, 46, 161, 233), @@ -502,7 +504,7 @@ class _DailyTaskPlanningScreenState extends State ), ], ), - SizedBox(height: 4), + const SizedBox(height: 4), MyText.bodySmall( "${(progress * 100).toStringAsFixed(1)}%", fontWeight: 500,