diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 9d6ecb7..501a94f 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -15,7 +15,7 @@ import 'package:marco/model/employees/employee_model.dart'; import 'package:marco/model/attendance/attendance_log_model.dart'; import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/attendance/attendance_log_view_model.dart'; - +import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/controller/project_controller.dart'; class AttendanceController extends GetxController { @@ -26,9 +26,13 @@ class AttendanceController extends GetxController { List attendanceLogs = []; List regularizationLogs = []; List attendenceLogsView = []; + // ------------------ Organizations ------------------ + List organizations = []; + Organization? selectedOrganization; + final isLoadingOrganizations = false.obs; // States - String selectedTab = 'Employee List'; +String selectedTab = 'todaysAttendance'; DateTime? startDateAttendance; DateTime? endDateAttendance; @@ -45,11 +49,16 @@ class AttendanceController extends GetxController { void onInit() { super.onInit(); _initializeDefaults(); + + // 🔹 Fetch organizations for the selected project + final projectId = Get.find().selectedProject?.id; + if (projectId != null) { + fetchOrganizations(projectId); + } } void _initializeDefaults() { _setDefaultDateRange(); - fetchProjects(); } void _setDefaultDateRange() { @@ -104,29 +113,15 @@ class AttendanceController extends GetxController { .toList(); } - Future fetchProjects() async { - isLoadingProjects.value = true; - - final response = await ApiService.getProjects(); - if (response != null && response.isNotEmpty) { - projects = response.map((e) => ProjectModel.fromJson(e)).toList(); - logSafe("Projects fetched: ${projects.length}"); - } else { - projects = []; - logSafe("Failed to fetch projects or no projects available.", - level: LogLevel.error); - } - - isLoadingProjects.value = false; - update(['attendance_dashboard_controller']); - } - - Future fetchEmployeesByProject(String? projectId) async { + Future fetchTodaysAttendance(String? projectId) async { if (projectId == null) return; isLoadingEmployees.value = true; - final response = await ApiService.getEmployeesByProject(projectId); + final response = await ApiService.getTodaysAttendance( + projectId, + organizationId: selectedOrganization?.id, + ); if (response != null) { employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); for (var emp in employees) { @@ -141,6 +136,20 @@ class AttendanceController extends GetxController { update(); } + Future fetchOrganizations(String projectId) async { + isLoadingOrganizations.value = true; + final response = await ApiService.getAssignedOrganizations(projectId); + if (response != null) { + organizations = response.data; + logSafe("Organizations fetched: ${organizations.length}"); + } else { + logSafe("Failed to fetch organizations for project $projectId", + level: LogLevel.error); + } + isLoadingOrganizations.value = false; + update(); + } + // ------------------ Attendance Capture ------------------ Future captureAndUploadAttendance( @@ -262,8 +271,12 @@ class AttendanceController extends GetxController { isLoadingAttendanceLogs.value = true; - final response = await ApiService.getAttendanceLogs(projectId, - dateFrom: dateFrom, dateTo: dateTo); + final response = await ApiService.getAttendanceLogs( + projectId, + dateFrom: dateFrom, + dateTo: dateTo, + organizationId: selectedOrganization?.id, + ); if (response != null) { attendanceLogs = response.map((e) => AttendanceLogModel.fromJson(e)).toList(); @@ -306,7 +319,10 @@ class AttendanceController extends GetxController { isLoadingRegularizationLogs.value = true; - final response = await ApiService.getRegularizationLogs(projectId); + final response = await ApiService.getRegularizationLogs( + projectId, + organizationId: selectedOrganization?.id, + ); if (response != null) { regularizationLogs = response.map((e) => RegularizationLogModel.fromJson(e)).toList(); @@ -354,14 +370,28 @@ class AttendanceController extends GetxController { Future fetchProjectData(String? projectId) async { if (projectId == null) return; - await Future.wait([ - fetchEmployeesByProject(projectId), - fetchAttendanceLogs(projectId, - dateFrom: startDateAttendance, dateTo: endDateAttendance), - fetchRegularizationLogs(projectId), - ]); + await fetchOrganizations(projectId); - logSafe("Project data fetched for project ID: $projectId"); + // Call APIs depending on the selected tab only + switch (selectedTab) { + case 'todaysAttendance': + await fetchTodaysAttendance(projectId); + break; + case 'attendanceLogs': + await fetchAttendanceLogs( + projectId, + dateFrom: startDateAttendance, + dateTo: endDateAttendance, + ); + break; + case 'regularizationRequests': + await fetchRegularizationLogs(projectId); + break; + } + + logSafe( + "Project data fetched for project ID: $projectId, tab: $selectedTab"); + update(); } // ------------------ UI Interaction ------------------ diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 833f19a..f52b28b 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -79,7 +79,6 @@ class LoginController extends MyController { enableRemoteLogging(); logSafe("✅ Remote logging enabled after login."); - final fcmToken = await LocalStorage.getFcmToken(); if (fcmToken?.isNotEmpty ?? false) { final success = await AuthService.registerDeviceToken(fcmToken!); @@ -90,9 +89,9 @@ class LoginController extends MyController { level: LogLevel.warning); } - logSafe("Login successful for user: ${loginData['username']}"); - Get.toNamed('/home'); + + Get.toNamed('/select_tenant'); } } catch (e, stacktrace) { logSafe("Exception during login", diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index d5b9d91..d3fdb92 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -94,8 +94,9 @@ class AddContactController extends GetxController { required List> phones, required String address, required String description, + String? designation, }) async { - if (isSubmitting.value) return; + if (isSubmitting.value) return; isSubmitting.value = true; final categoryId = categoriesMap[selectedCategory.value]; @@ -156,6 +157,8 @@ class AddContactController extends GetxController { if (phones.isNotEmpty) "contactPhones": phones, if (address.trim().isNotEmpty) "address": address.trim(), if (description.trim().isNotEmpty) "description": description.trim(), + if (designation != null && designation.trim().isNotEmpty) + "designation": designation.trim(), }; logSafe("${id != null ? 'Updating' : 'Creating'} contact"); diff --git a/lib/controller/directory/directory_controller.dart b/lib/controller/directory/directory_controller.dart index 4b1e887..8de4291 100644 --- a/lib/controller/directory/directory_controller.dart +++ b/lib/controller/directory/directory_controller.dart @@ -97,10 +97,13 @@ class DirectoryController extends GetxController { } } - Future fetchCommentsForContact(String contactId) async { + Future fetchCommentsForContact(String contactId, + {bool active = true}) async { try { - final data = await ApiService.getDirectoryComments(contactId); - logSafe("Fetched comments for contact $contactId: $data"); + final data = + await ApiService.getDirectoryComments(contactId, active: active); + logSafe( + "Fetched ${active ? 'active' : 'inactive'} comments for contact $contactId: $data"); final comments = data?.map((e) => DirectoryComment.fromJson(e)).toList() ?? []; @@ -112,7 +115,8 @@ class DirectoryController extends GetxController { contactCommentsMap[contactId]!.assignAll(comments); contactCommentsMap[contactId]?.refresh(); } catch (e) { - logSafe("Error fetching comments for contact $contactId: $e", + logSafe( + "Error fetching ${active ? 'active' : 'inactive'} comments for contact $contactId: $e", level: LogLevel.error); contactCommentsMap[contactId] ??= [].obs; @@ -120,6 +124,80 @@ class DirectoryController extends GetxController { } } + /// 🗑️ Delete a comment (soft delete) + Future deleteComment(String commentId, String contactId) async { + try { + logSafe("Deleting comment. id: $commentId"); + + final success = await ApiService.restoreContactComment(commentId, false); + + if (success) { + logSafe("Comment deleted successfully. id: $commentId"); + + // Refresh comments after deletion + await fetchCommentsForContact(contactId); + + showAppSnackbar( + title: "Deleted", + message: "Comment deleted successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Failed to delete comment via API. id: $commentId"); + showAppSnackbar( + title: "Error", + message: "Failed to delete comment.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Delete comment failed: ${e.toString()}", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "Something went wrong while deleting comment.", + type: SnackbarType.error, + ); + } + } + + /// ♻️ Restore a previously deleted comment + Future restoreComment(String commentId, String contactId) async { + try { + logSafe("Restoring comment. id: $commentId"); + + final success = await ApiService.restoreContactComment(commentId, true); + + if (success) { + logSafe("Comment restored successfully. id: $commentId"); + + // Refresh comments after restore + await fetchCommentsForContact(contactId); + + showAppSnackbar( + title: "Restored", + message: "Comment restored successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("Failed to restore comment via API. id: $commentId"); + showAppSnackbar( + title: "Error", + message: "Failed to restore comment.", + type: SnackbarType.error, + ); + } + } catch (e, stack) { + logSafe("Restore comment failed: ${e.toString()}", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + showAppSnackbar( + title: "Error", + message: "Something went wrong while restoring comment.", + type: SnackbarType.error, + ); + } + } + Future fetchBuckets() async { try { final response = await ApiService.getContactBucketList(); diff --git a/lib/controller/directory/notes_controller.dart b/lib/controller/directory/notes_controller.dart index 709b4e0..1868414 100644 --- a/lib/controller/directory/notes_controller.dart +++ b/lib/controller/directory/notes_controller.dart @@ -107,6 +107,49 @@ class NotesController extends GetxController { } } + Future restoreOrDeleteNote(NoteModel note, + {bool restore = true}) async { + final action = restore ? "restore" : "delete"; + + try { + logSafe("Attempting to $action note id: ${note.id}"); + + final success = await ApiService.restoreContactComment( + note.id, + restore, // true = restore, false = delete + ); + + if (success) { + final index = notesList.indexWhere((n) => n.id == note.id); + if (index != -1) { + notesList[index] = note.copyWith(isActive: restore); + notesList.refresh(); + } + showAppSnackbar( + title: restore ? "Restored" : "Deleted", + message: restore + ? "Note has been restored successfully." + : "Note has been deleted successfully.", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: + restore ? "Failed to restore note." : "Failed to delete note.", + type: SnackbarType.error, + ); + } + } catch (e, st) { + logSafe("$action note failed: $e", error: e, stackTrace: st); + showAppSnackbar( + title: "Error", + message: "Something went wrong while trying to $action the note.", + type: SnackbarType.error, + ); + } + } + void addNote(NoteModel note) { notesList.insert(0, note); logSafe("Note added to list"); diff --git a/lib/controller/employee/add_employee_controller.dart b/lib/controller/employee/add_employee_controller.dart index 4d51351..44aa286 100644 --- a/lib/controller/employee/add_employee_controller.dart +++ b/lib/controller/employee/add_employee_controller.dart @@ -1,13 +1,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:marco/controller/my_controller.dart'; -import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:marco/helpers/services/app_logger.dart'; -import 'package:collection/collection.dart'; enum Gender { male, @@ -18,22 +18,26 @@ enum Gender { } class AddEmployeeController extends MyController { - Map? editingEmployeeData; // For edit mode + Map? editingEmployeeData; - List files = []; + // State final MyFormValidator basicValidator = MyFormValidator(); + final List files = []; + final List categories = []; + Gender? selectedGender; List> roles = []; String? selectedRoleId; - String selectedCountryCode = "+91"; + String selectedCountryCode = '+91'; bool showOnline = true; - final List categories = []; DateTime? joiningDate; + String? selectedOrganizationId; + RxString selectedOrganizationName = RxString(''); @override void onInit() { super.onInit(); - logSafe("Initializing AddEmployeeController..."); + logSafe('Initializing AddEmployeeController...'); _initializeFields(); fetchRoles(); @@ -45,29 +49,36 @@ class AddEmployeeController extends MyController { void _initializeFields() { basicValidator.addField( 'first_name', - label: "First Name", + label: 'First Name', required: true, controller: TextEditingController(), ); basicValidator.addField( 'phone_number', - label: "Phone Number", + label: 'Phone Number', required: true, controller: TextEditingController(), ); basicValidator.addField( 'last_name', - label: "Last Name", + label: 'Last Name', required: true, controller: TextEditingController(), ); - logSafe("Fields initialized for first_name, phone_number, last_name."); + // Email is optional in controller; UI enforces when application access is checked + basicValidator.addField( + 'email', + label: 'Email', + required: false, + controller: TextEditingController(), + ); + + logSafe('Fields initialized for first_name, phone_number, last_name, email.'); } - /// Prefill fields in edit mode - // In AddEmployeeController + // Prefill fields in edit mode void prefillFields() { - logSafe("Prefilling data for editing..."); + logSafe('Prefilling data for editing...'); basicValidator.getController('first_name')?.text = editingEmployeeData?['first_name'] ?? ''; basicValidator.getController('last_name')?.text = @@ -76,10 +87,12 @@ class AddEmployeeController extends MyController { editingEmployeeData?['phone_number'] ?? ''; selectedGender = editingEmployeeData?['gender'] != null - ? Gender.values - .firstWhereOrNull((g) => g.name == editingEmployeeData!['gender']) + ? Gender.values.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender']) : null; + basicValidator.getController('email')?.text = + editingEmployeeData?['email'] ?? ''; + selectedRoleId = editingEmployeeData?['job_role_id']; if (editingEmployeeData?['joining_date'] != null) { @@ -91,92 +104,102 @@ class AddEmployeeController extends MyController { void setJoiningDate(DateTime date) { joiningDate = date; - logSafe("Joining date selected: $date"); + logSafe('Joining date selected: $date'); update(); } void onGenderSelected(Gender? gender) { selectedGender = gender; - logSafe("Gender selected: ${gender?.name}"); + logSafe('Gender selected: ${gender?.name}'); update(); } Future fetchRoles() async { - logSafe("Fetching roles..."); + logSafe('Fetching roles...'); try { final result = await ApiService.getRoles(); if (result != null) { roles = List>.from(result); - logSafe("Roles fetched successfully."); + logSafe('Roles fetched successfully.'); update(); } else { - logSafe("Failed to fetch roles: null result", level: LogLevel.error); + logSafe('Failed to fetch roles: null result', level: LogLevel.error); } } catch (e, st) { - logSafe("Error fetching roles", - level: LogLevel.error, error: e, stackTrace: st); + logSafe('Error fetching roles', level: LogLevel.error, error: e, stackTrace: st); } } void onRoleSelected(String? roleId) { selectedRoleId = roleId; - logSafe("Role selected: $roleId"); + logSafe('Role selected: $roleId'); update(); } - /// Create or update employee - Future?> createOrUpdateEmployee() async { + // Create or update employee + Future?> createOrUpdateEmployee({ + String? email, + bool hasApplicationAccess = false, + }) async { logSafe(editingEmployeeData != null - ? "Starting employee update..." - : "Starting employee creation..."); + ? 'Starting employee update...' + : 'Starting employee creation...'); if (selectedGender == null || selectedRoleId == null) { showAppSnackbar( - title: "Missing Fields", - message: "Please select both Gender and Role.", + title: 'Missing Fields', + message: 'Please select both Gender and Role.', type: SnackbarType.warning, ); return null; } - final firstName = basicValidator.getController("first_name")?.text.trim(); - final lastName = basicValidator.getController("last_name")?.text.trim(); - final phoneNumber = - basicValidator.getController("phone_number")?.text.trim(); + final firstName = basicValidator.getController('first_name')?.text.trim(); + final lastName = basicValidator.getController('last_name')?.text.trim(); + final phoneNumber = basicValidator.getController('phone_number')?.text.trim(); try { + // sanitize orgId before sending + final String? orgId = (selectedOrganizationId != null && + selectedOrganizationId!.trim().isNotEmpty) + ? selectedOrganizationId + : null; + final response = await ApiService.createEmployee( - id: editingEmployeeData?['id'], // Pass id if editing + id: editingEmployeeData?['id'], firstName: firstName!, lastName: lastName!, phoneNumber: phoneNumber!, gender: selectedGender!.name, jobRoleId: selectedRoleId!, - joiningDate: joiningDate?.toIso8601String() ?? "", + joiningDate: joiningDate?.toIso8601String() ?? '', + organizationId: orgId, + email: email, + hasApplicationAccess: hasApplicationAccess, ); - logSafe("Response: $response"); + logSafe('Response: $response'); if (response != null && response['success'] == true) { showAppSnackbar( - title: "Success", + title: 'Success', message: editingEmployeeData != null - ? "Employee updated successfully!" - : "Employee created successfully!", + ? 'Employee updated successfully!' + : 'Employee created successfully!', type: SnackbarType.success, ); return response; } else { - logSafe("Failed operation", level: LogLevel.error); + logSafe('Failed operation', level: LogLevel.error); } } catch (e, st) { - logSafe("Error creating/updating employee", + logSafe('Error creating/updating employee', level: LogLevel.error, error: e, stackTrace: st); } showAppSnackbar( - title: "Error", - message: "Failed to save employee.", + title: 'Error', + message: 'Failed to save employee.', type: SnackbarType.error, ); return null; @@ -192,9 +215,8 @@ class AddEmployeeController extends MyController { } showAppSnackbar( - title: "Permission Required", - message: - "Please allow Contacts permission from settings to pick a contact.", + title: 'Permission Required', + message: 'Please allow Contacts permission from settings to pick a contact.', type: SnackbarType.warning, ); return false; @@ -212,8 +234,8 @@ class AddEmployeeController extends MyController { await FlutterContacts.getContact(picked.id, withProperties: true); if (contact == null) { showAppSnackbar( - title: "Error", - message: "Failed to load contact details.", + title: 'Error', + message: 'Failed to load contact details.', type: SnackbarType.error, ); return; @@ -221,8 +243,8 @@ class AddEmployeeController extends MyController { if (contact.phones.isEmpty) { showAppSnackbar( - title: "No Phone Number", - message: "Selected contact has no phone number.", + title: 'No Phone Number', + message: 'Selected contact has no phone number.', type: SnackbarType.warning, ); return; @@ -236,8 +258,8 @@ class AddEmployeeController extends MyController { if (indiaPhones.isEmpty) { showAppSnackbar( - title: "No Indian Number", - message: "Selected contact has no Indian (+91) phone number.", + title: 'No Indian Number', + message: 'Selected contact has no Indian (+91) phone number.', type: SnackbarType.warning, ); return; @@ -250,19 +272,20 @@ class AddEmployeeController extends MyController { selectedPhone = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text("Choose an Indian number"), + title: const Text('Choose an Indian number'), content: Column( mainAxisSize: MainAxisSize.min, children: indiaPhones - .map((p) => ListTile( - title: Text(p.number), - onTap: () => Navigator.of(ctx).pop(p.number), - )) + .map( + (p) => ListTile( + title: Text(p.number), + onTap: () => Navigator.of(ctx).pop(p.number), + ), + ) .toList(), ), ), ); - if (selectedPhone == null) return; } @@ -275,11 +298,11 @@ class AddEmployeeController extends MyController { phoneWithoutCountryCode; update(); } catch (e, st) { - logSafe("Error fetching contacts", + logSafe('Error fetching contacts', level: LogLevel.error, error: e, stackTrace: st); showAppSnackbar( - title: "Error", - message: "Failed to fetch contacts.", + title: 'Error', + message: 'Failed to fetch contacts.', type: SnackbarType.error, ); } diff --git a/lib/controller/employee/employees_screen_controller.dart b/lib/controller/employee/employees_screen_controller.dart index 2339796..3841a76 100644 --- a/lib/controller/employee/employees_screen_controller.dart +++ b/lib/controller/employee/employees_screen_controller.dart @@ -24,7 +24,7 @@ class EmployeesScreenController extends GetxController { @override void onInit() { super.onInit(); - isLoading.value = true; + isLoading.value = true; fetchAllProjects().then((_) { final projectId = Get.find().selectedProject?.id; if (projectId != null) { @@ -66,21 +66,26 @@ class EmployeesScreenController extends GetxController { update(['employee_screen_controller']); } - Future fetchAllEmployees() async { + Future fetchAllEmployees({String? organizationId}) async { isLoading.value = true; update(['employee_screen_controller']); await _handleApiCall( - ApiService.getAllEmployees, + () => ApiService.getAllEmployees( + organizationId: organizationId), // pass orgId to API onSuccess: (data) { employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); - logSafe("All Employees fetched: ${employees.length} employees loaded.", - level: LogLevel.info); + logSafe( + "All Employees fetched: ${employees.length} employees loaded.", + level: LogLevel.info, + ); }, onEmpty: () { employees.clear(); - logSafe("No Employee data found or API call failed.", - level: LogLevel.warning); + logSafe( + "No Employee data found or API call failed", + level: LogLevel.warning, + ); }, ); @@ -88,43 +93,22 @@ class EmployeesScreenController extends GetxController { update(['employee_screen_controller']); } - 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 fetchEmployeesByProject(String projectId, + {String? organizationId}) async { + if (projectId.isEmpty) return; isLoading.value = true; await _handleApiCall( - () => ApiService.getAllEmployeesByProject(projectId), + () => ApiService.getAllEmployeesByProject(projectId, + organizationId: organizationId), onSuccess: (data) { employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); - for (var emp in employees) { uploadingStates[emp.id] = false.obs; } - - logSafe( - "Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, - ); - }, - onEmpty: () { - employees.clear(); - logSafe( - "No employees found for project $projectId.", - level: LogLevel.warning, - ); - }, - onError: (e) { - logSafe( - "Error fetching employees for project $projectId", - level: LogLevel.error, - error: e, - ); }, + onEmpty: () => employees.clear(), ); isLoading.value = false; diff --git a/lib/controller/task_planning/daily_task_controller.dart b/lib/controller/task_planning/daily_task_controller.dart index f1a3501..644bc9f 100644 --- a/lib/controller/task_planning/daily_task_controller.dart +++ b/lib/controller/task_planning/daily_task_controller.dart @@ -24,8 +24,12 @@ class DailyTaskController extends GetxController { } RxBool isLoading = true.obs; + RxBool isLoadingMore = false.obs; Map> groupedDailyTasks = {}; - + // Pagination + int currentPage = 1; + int pageSize = 20; + bool hasMore = true; @override void onInit() { super.onInit(); @@ -47,48 +51,49 @@ class DailyTaskController extends GetxController { ); } - Future fetchTaskData(String? projectId) async { - if (projectId == null) { - logSafe("fetchTaskData: Skipped, projectId is null", - level: LogLevel.warning); - return; + Future fetchTaskData( + String projectId, { + List? serviceIds, + int pageNumber = 1, + int pageSize = 20, + bool isLoadMore = false, + }) async { + if (!isLoadMore) { + isLoading.value = true; + currentPage = 1; + hasMore = true; + groupedDailyTasks.clear(); + dailyTasks.clear(); + } else { + isLoadingMore.value = true; } - isLoading.value = true; - final response = await ApiService.getDailyTasks( projectId, dateFrom: startDateTask, dateTo: endDateTask, + serviceIds: serviceIds, + pageNumber: pageNumber, + pageSize: pageSize, ); - isLoading.value = false; - - if (response != null) { - groupedDailyTasks.clear(); - + if (response != null && response.isNotEmpty) { for (var taskJson in response) { final task = TaskModel.fromJson(taskJson); final assignmentDateKey = task.assignmentDate.toIso8601String().split('T')[0]; - groupedDailyTasks.putIfAbsent(assignmentDateKey, () => []).add(task); } - dailyTasks = groupedDailyTasks.values.expand((list) => list).toList(); - - logSafe( - "Daily tasks fetched and grouped: ${dailyTasks.length} for project $projectId", - level: LogLevel.info, - ); - - update(); + currentPage = pageNumber; } else { - logSafe( - "Failed to fetch daily tasks for project $projectId", - level: LogLevel.error, - ); + hasMore = false; } + + isLoading.value = false; + isLoadingMore.value = false; + + update(); } Future selectDateRangeForTaskData( @@ -119,17 +124,23 @@ class DailyTaskController extends GetxController { level: LogLevel.info, ); - await controller.fetchTaskData(controller.selectedProjectId); + // ✅ Add null check before calling fetchTaskData + final projectId = controller.selectedProjectId; + if (projectId != null && projectId.isNotEmpty) { + await controller.fetchTaskData(projectId); + } else { + logSafe("Project ID is null or empty, skipping fetchTaskData", + level: LogLevel.warning); + } } -void refreshTasksFromNotification({ - required String projectId, - required String taskAllocationId, -}) async { - // re-fetch tasks - await fetchTaskData(projectId); - - update(); // rebuilds UI -} + void refreshTasksFromNotification({ + required String projectId, + required String taskAllocationId, + }) async { + // re-fetch tasks + await fetchTaskData(projectId); + update(); // rebuilds UI + } } diff --git a/lib/controller/task_planning/daily_task_planning_controller.dart b/lib/controller/task_planning/daily_task_planning_controller.dart index 540b7c7..f94af8b 100644 --- a/lib/controller/task_planning/daily_task_planning_controller.dart +++ b/lib/controller/task_planning/daily_task_planning_controller.dart @@ -131,7 +131,7 @@ class DailyTaskPlanningController extends GetxController { } /// Fetch Infra details and then tasks per work area - Future fetchTaskData(String? projectId) async { + Future fetchTaskData(String? projectId, {String? serviceId}) async { if (projectId == null) { logSafe("Project ID is null", level: LogLevel.warning); return; @@ -139,6 +139,7 @@ class DailyTaskPlanningController extends GetxController { isLoading.value = true; try { + // Fetch infra details final infraResponse = await ApiService.getInfraDetails(projectId); final infraData = infraResponse?['data'] as List?; @@ -159,11 +160,12 @@ class DailyTaskPlanningController extends GetxController { return Floor( id: floorJson['id'], floorName: floorJson['floorName'], - workAreas: (floorJson['workAreas'] as List).map((areaJson) { + workAreas: + (floorJson['workAreas'] as List).map((areaJson) { return WorkArea( id: areaJson['id'], areaName: areaJson['areaName'], - workItems: [], // Initially empty, will fill after tasks API + workItems: [], // Will fill after tasks API ); }).toList(), ); @@ -182,13 +184,17 @@ class DailyTaskPlanningController extends GetxController { ); }).toList(); - // Fetch tasks for each work area - await Future.wait(dailyTasks.expand((task) => task.buildings) + // 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); + final taskResponse = await ApiService.getWorkItemsByWorkArea( + area.id, + // serviceId: serviceId, // <-- only pass if not null + ); final taskData = taskResponse?['data'] as List? ?? []; area.workItems.addAll(taskData.map((taskJson) { @@ -200,11 +206,13 @@ class DailyTaskPlanningController extends GetxController { ? ActivityMaster.fromJson(taskJson['activityMaster']) : null, workCategoryMaster: taskJson['workCategoryMaster'] != null - ? WorkCategoryMaster.fromJson(taskJson['workCategoryMaster']) + ? WorkCategoryMaster.fromJson( + taskJson['workCategoryMaster']) : null, plannedWork: (taskJson['plannedWork'] as num?)?.toDouble(), completedWork: (taskJson['completedWork'] as num?)?.toDouble(), - todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(), + todaysAssigned: + (taskJson['todaysAssigned'] as num?)?.toDouble(), description: taskJson['description'] as String?, taskDate: taskJson['taskDate'] != null ? DateTime.tryParse(taskJson['taskDate']) @@ -221,7 +229,8 @@ class DailyTaskPlanningController extends GetxController { 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); + logSafe("Error fetching daily task data", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; update(); diff --git a/lib/controller/tenant/organization_selection_controller.dart b/lib/controller/tenant/organization_selection_controller.dart new file mode 100644 index 0000000..ee6db6e --- /dev/null +++ b/lib/controller/tenant/organization_selection_controller.dart @@ -0,0 +1,52 @@ +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/attendance/organization_per_project_list_model.dart'; + +class OrganizationController extends GetxController { + /// List of organizations assigned to the selected project + List organizations = []; + + /// Currently selected organization (reactive) + Rxn selectedOrganization = Rxn(); + + /// Loading state for fetching organizations + final isLoadingOrganizations = false.obs; + + /// Fetch organizations assigned to a given project + Future fetchOrganizations(String projectId) async { + try { + isLoadingOrganizations.value = true; + + final response = await ApiService.getAssignedOrganizations(projectId); + if (response != null && response.data.isNotEmpty) { + organizations = response.data; + logSafe("Organizations fetched: ${organizations.length}"); + } else { + organizations = []; + logSafe("No organizations found for project $projectId", + level: LogLevel.warning); + } + } catch (e, stackTrace) { + logSafe("Failed to fetch organizations: $e", + level: LogLevel.error, error: e, stackTrace: stackTrace); + organizations = []; + } finally { + isLoadingOrganizations.value = false; + } + } + + /// Select an organization + void selectOrganization(Organization? org) { + selectedOrganization.value = org; + } + + /// Clear the selection (set to "All Organizations") + void clearSelection() { + selectedOrganization.value = null; + } + + /// Current selection name for UI + String get currentSelection => + selectedOrganization.value?.name ?? "All Organizations"; +} diff --git a/lib/controller/tenant/service_controller.dart b/lib/controller/tenant/service_controller.dart new file mode 100644 index 0000000..f832157 --- /dev/null +++ b/lib/controller/tenant/service_controller.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/tenant/tenant_services_model.dart'; + +class ServiceController extends GetxController { + List services = []; + Service? selectedService; + final isLoadingServices = false.obs; + + /// Fetch services assigned to a project + Future fetchServices(String projectId) async { + try { + isLoadingServices.value = true; + final response = await ApiService.getAssignedServices(projectId); + if (response != null) { + services = response.data; + logSafe("Services fetched: ${services.length}"); + } else { + logSafe("Failed to fetch services for project $projectId", + level: LogLevel.error); + } + } finally { + isLoadingServices.value = false; + update(); + } + } + + /// Select a service + void selectService(Service? service) { + selectedService = service; + update(); + } + + /// Clear selection + void clearSelection() { + selectedService = null; + update(); + } + + /// Current selected name + String get currentSelection => selectedService?.name ?? "All Services"; +} diff --git a/lib/controller/tenant/tenant_selection_controller.dart b/lib/controller/tenant/tenant_selection_controller.dart new file mode 100644 index 0000000..7789de9 --- /dev/null +++ b/lib/controller/tenant/tenant_selection_controller.dart @@ -0,0 +1,106 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; +import 'package:marco/model/tenant/tenant_list_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; + +class TenantSelectionController extends GetxController { + final TenantService _tenantService = TenantService(); + + var tenants = [].obs; + var isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + loadTenants(); + } + + /// Load tenants from API + Future loadTenants({bool fromTenantSelectionScreen = false}) async { + try { + isLoading.value = true; + final data = await _tenantService.getTenants(); + if (data != null) { + tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); + + final recentTenantId = LocalStorage.getRecentTenantId(); + + // ✅ If user came from TenantSelectionScreen & recent tenant exists, auto-select + if (fromTenantSelectionScreen && recentTenantId != null) { + final tenantExists = tenants.any((t) => t.id == recentTenantId); + if (tenantExists) { + await onTenantSelected(recentTenantId); + return; + } else { + // if tenant is no longer valid, clear recentTenant + await LocalStorage.removeRecentTenantId(); + } + } + + // ✅ Auto-select if only one tenant + if (tenants.length == 1) { + await onTenantSelected(tenants.first.id); + } + } else { + tenants.clear(); + logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); + } + } catch (e, st) { + logSafe("❌ Exception in loadTenants", + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isLoading.value = false; + } + } + + /// Select tenant + Future onTenantSelected(String tenantId) async { + try { + isLoading.value = true; + final success = await _tenantService.selectTenant(tenantId); + if (success) { + logSafe("✅ Tenant selection successful: $tenantId"); + + // Store selected tenant in memory + final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); + TenantService.setSelectedTenant(selectedTenant); + + // 🔥 Save in LocalStorage + await LocalStorage.setRecentTenantId(tenantId); + + // Navigate to dashboard + Get.offAllNamed('/dashboard'); + + showAppSnackbar( + title: "Success", + message: "Organization selected successfully.", + type: SnackbarType.success, + ); + } else { + logSafe("❌ Tenant selection failed for: $tenantId", + level: LogLevel.warning); + + // Show error snackbar + showAppSnackbar( + title: "Error", + message: "Unable to select organization. Please try again.", + type: SnackbarType.error, + ); + } + } catch (e, st) { + logSafe("❌ Exception in onTenantSelected", + level: LogLevel.error, error: e, stackTrace: st); + + // Show error snackbar for exception + showAppSnackbar( + title: "Error", + message: "An unexpected error occurred while selecting organization.", + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index eee0216..960c3a7 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -14,7 +14,7 @@ class ApiEndpoints { // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; - static const String getEmployeesByProject = "/attendance/project/team"; + static const String getTodaysAttendance = "/attendance/project/team"; static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getRegularizationLogs = "/attendance/regularize"; @@ -25,7 +25,7 @@ class ApiEndpoints { static const String getAllEmployees = "/employee/list"; static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getRoles = "/roles/jobrole"; - static const String createEmployee = "/employee/manage-mobile"; + static const String createEmployee = "/employee/app/manage"; static const String getEmployeeInfo = "/employee/profile/get"; static const String assignEmployee = "/employee/profile/get"; static const String getAssignedProjects = "/project/assigned-projects"; @@ -90,4 +90,8 @@ class ApiEndpoints { /// Logs Module API Endpoints static const String uploadLogs = "/log"; + + static const String getAssignedOrganizations = + "/project/get/assigned/organization"; + 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 b660075..1f15ef4 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -18,9 +18,10 @@ import 'package:marco/model/document/master_document_tags.dart'; import 'package:marco/model/document/master_document_type_model.dart'; import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; +import 'package:marco/model/attendance/organization_per_project_list_model.dart'; +import 'package:marco/model/tenant/tenant_services_model.dart'; class ApiService { - static const Duration timeout = Duration(seconds: 30); static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); @@ -137,8 +138,9 @@ class ApiService { logSafe("Headers: ${_headers(token)}", level: LogLevel.debug); try { - final response = - await http.get(uri, headers: _headers(token)).timeout(timeout); + final response = await http + .get(uri, headers: _headers(token)) + .timeout(extendedTimeout); logSafe("Response Status: ${response.statusCode}", level: LogLevel.debug); logSafe("Response Body: ${response.body}", level: LogLevel.debug); @@ -172,7 +174,7 @@ class ApiService { static Future _postRequest( String endpoint, dynamic body, { - Duration customTimeout = timeout, + Duration customTimeout = extendedTimeout, bool hasRetried = false, }) async { String? token = await _getToken(); @@ -206,7 +208,7 @@ class ApiService { String endpoint, dynamic body, { Map? additionalHeaders, - Duration customTimeout = timeout, + Duration customTimeout = extendedTimeout, bool hasRetried = false, }) async { String? token = await _getToken(); @@ -247,6 +249,106 @@ class ApiService { } } + static Future _deleteRequest( + String endpoint, { + Map? additionalHeaders, + Duration customTimeout = extendedTimeout, + bool hasRetried = false, + }) async { + String? token = await _getToken(); + if (token == null) return null; + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + final headers = { + ..._headers(token), + if (additionalHeaders != null) ...additionalHeaders, + }; + + logSafe("DELETE $uri\nHeaders: $headers"); + + try { + final response = + await http.delete(uri, headers: headers).timeout(customTimeout); + + if (response.statusCode == 401 && !hasRetried) { + logSafe("Unauthorized DELETE. Attempting token refresh..."); + if (await AuthService.refreshToken()) { + return await _deleteRequest( + endpoint, + additionalHeaders: additionalHeaders, + customTimeout: customTimeout, + hasRetried: true, + ); + } + } + + return response; + } catch (e) { + logSafe("HTTP DELETE Exception: $e", level: LogLevel.error); + return null; + } + } + + /// Get Organizations assigned to a Project + static Future getAssignedOrganizations( + String projectId) async { + final endpoint = "${ApiEndpoints.getAssignedOrganizations}/$projectId"; + logSafe("Fetching organizations assigned to projectId: $projectId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Assigned Organizations request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Assigned Organizations"); + + if (jsonResponse != null) { + return OrganizationListResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getAssignedOrganizations: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + //// Get Services assigned to a Project + static Future getAssignedServices( + String projectId) async { + final endpoint = "${ApiEndpoints.getAssignedServices}/$projectId"; + logSafe("Fetching services assigned to projectId: $projectId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Assigned Services request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Assigned Services"); + + if (jsonResponse != null) { + return ServiceListResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getAssignedServices: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + static Future postLogsApi(List> logs) async { const endpoint = "${ApiEndpoints.uploadLogs}"; logSafe("Posting logs... count=${logs.length}"); @@ -868,8 +970,9 @@ class ApiService { logSafe("Sending DELETE request to $uri", level: LogLevel.debug); - final response = - await http.delete(uri, headers: _headers(token)).timeout(timeout); + final response = await http + .delete(uri, headers: _headers(token)) + .timeout(extendedTimeout); logSafe("DELETE expense response status: ${response.statusCode}"); logSafe("DELETE expense response body: ${response.body}"); @@ -1281,8 +1384,9 @@ class ApiService { logSafe("Sending DELETE request to $uri", level: LogLevel.debug); - final response = - await http.delete(uri, headers: _headers(token)).timeout(timeout); + final response = await http + .delete(uri, headers: _headers(token)) + .timeout(extendedTimeout); logSafe("DELETE bucket response status: ${response.statusCode}"); logSafe("DELETE bucket response body: ${response.body}"); @@ -1615,16 +1719,62 @@ class ApiService { return false; } - static Future?> getDirectoryComments(String contactId) async { - final url = "${ApiEndpoints.getDirectoryNotes}/$contactId"; - final response = await _getRequest(url); - final data = response != null - ? _parseResponse(response, label: 'Directory Comments') - : null; + static Future restoreContactComment( + String commentId, + bool isActive, + ) async { + final endpoint = + "${ApiEndpoints.updateDirectoryNotes}/$commentId?active=$isActive"; - return data is List ? data : null; + logSafe( + "Updating comment active status. commentId: $commentId, isActive: $isActive"); + logSafe("Sending request to $endpoint "); + + try { + final response = await _deleteRequest( + endpoint, + ); + + if (response == null) { + logSafe("Update comment failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Update comment response status: ${response.statusCode}"); + logSafe("Update comment response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe( + "Comment active status updated successfully. commentId: $commentId"); + return true; + } else { + logSafe("Failed to update comment: ${json['message']}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during updateComment API: ${e.toString()}", + level: LogLevel.error); + logSafe("StackTrace: ${stack.toString()}", level: LogLevel.debug); + } + + return false; } +static Future?> getDirectoryComments( + String contactId, { + bool active = true, +}) async { + final url = "${ApiEndpoints.getDirectoryNotes}/$contactId?active=$active"; + final response = await _getRequest(url); + final data = response != null + ? _parseResponse(response, label: 'Directory Comments') + : null; + + return data is List ? data : null; +} + + static Future updateContact( String contactId, Map payload) async { try { @@ -1733,23 +1883,49 @@ class ApiService { _getRequest(ApiEndpoints.getGlobalProjects).then((res) => res != null ? _parseResponse(res, label: 'Global Projects') : null); - static Future?> getEmployeesByProject(String projectId) async => - _getRequest(ApiEndpoints.getEmployeesByProject, - queryParams: {"projectId": projectId}) - .then((res) => - res != null ? _parseResponse(res, label: 'Employees') : null); + static Future?> getTodaysAttendance( + String projectId, { + String? organizationId, + }) async { + final query = { + "projectId": projectId, + if (organizationId != null) "organizationId": organizationId, + }; + + return _getRequest(ApiEndpoints.getTodaysAttendance, queryParams: query) + .then((res) => + res != null ? _parseResponse(res, label: 'Employees') : null); + } + + static Future?> getRegularizationLogs( + String projectId, { + String? organizationId, + }) async { + final query = { + "projectId": projectId, + if (organizationId != null) "organizationId": organizationId, + }; + + return _getRequest(ApiEndpoints.getRegularizationLogs, queryParams: query) + .then((res) => res != null + ? _parseResponse(res, label: 'Regularization Logs') + : null); + } static Future?> getAttendanceLogs( String projectId, { DateTime? dateFrom, DateTime? dateTo, + String? organizationId, }) async { final query = { "projectId": projectId, if (dateFrom != null) "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), + if (organizationId != null) "organizationId": organizationId, }; + return _getRequest(ApiEndpoints.getAttendanceLogs, queryParams: query).then( (res) => res != null ? _parseResponse(res, label: 'Attendance Logs') : null); @@ -1759,13 +1935,6 @@ class ApiService { _getRequest("${ApiEndpoints.getAttendanceLogView}/$id").then((res) => res != null ? _parseResponse(res, label: 'Log Details') : null); - static Future?> getRegularizationLogs(String projectId) async => - _getRequest(ApiEndpoints.getRegularizationLogs, - queryParams: {"projectId": projectId}) - .then((res) => res != null - ? _parseResponse(res, label: 'Regularization Logs') - : null); - static Future uploadAttendanceImage( String id, String employeeId, @@ -1859,11 +2028,15 @@ class ApiService { return null; } - static Future?> getAllEmployeesByProject( - String projectId) async { + static Future?> getAllEmployeesByProject(String projectId, + {String? organizationId}) async { if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); - final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; + // Build the endpoint with optional organizationId query + var endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; + if (organizationId != null && organizationId.isNotEmpty) { + endpoint += "?organizationId=$organizationId"; + } return _getRequest(endpoint).then( (res) => res != null @@ -1872,9 +2045,19 @@ class ApiService { ); } - static Future?> getAllEmployees() async => - _getRequest(ApiEndpoints.getAllEmployees).then((res) => - res != null ? _parseResponse(res, label: 'All Employees') : null); + static Future?> getAllEmployees( + {String? organizationId}) async { + var endpoint = ApiEndpoints.getAllEmployees; + + // Add organization filter if provided + if (organizationId != null && organizationId.isNotEmpty) { + endpoint += "?organizationId=$organizationId"; + } + + return _getRequest(endpoint).then( + (res) => res != null ? _parseResponse(res, label: 'All Employees') : null, + ); + } static Future?> getRoles() async => _getRequest(ApiEndpoints.getRoles).then( @@ -1887,6 +2070,9 @@ class ApiService { required String gender, required String jobRoleId, required String joiningDate, + String? email, + String? organizationId, + bool? hasApplicationAccess, }) async { final body = { if (id != null) "id": id, @@ -1896,6 +2082,11 @@ class ApiService { "gender": gender, "jobRoleId": jobRoleId, "joiningDate": joiningDate, + if (email != null && email.isNotEmpty) "email": email, + if (organizationId != null && organizationId.isNotEmpty) + "organizationId": organizationId, + if (hasApplicationAccess != null) + "hasApplicationAccess": hasApplicationAccess, }; final response = await _postRequest( @@ -1929,16 +2120,32 @@ class ApiService { String projectId, { DateTime? dateFrom, DateTime? dateTo, + List? serviceIds, + int pageNumber = 1, + int pageSize = 20, }) async { + final filterBody = { + "serviceIds": serviceIds ?? [], + }; + final query = { "projectId": projectId, + "pageNumber": pageNumber.toString(), + "pageSize": pageSize.toString(), if (dateFrom != null) "dateFrom": DateFormat('yyyy-MM-dd').format(dateFrom), if (dateTo != null) "dateTo": DateFormat('yyyy-MM-dd').format(dateTo), + "filter": jsonEncode(filterBody), }; - return _getRequest(ApiEndpoints.getDailyTask, queryParams: query).then( - (res) => - res != null ? _parseResponse(res, label: 'Daily Tasks') : null); + + final uri = + Uri.parse(ApiEndpoints.getDailyTask).replace(queryParameters: query); + + final response = await _getRequest(uri.toString()); + + return response != null + ? _parseResponse(response, label: 'Daily Tasks') + : null; } static Future reportTask({ diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 1fa0da4..54db545 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -1,15 +1,14 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:url_strategy/url_strategy.dart'; -import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/auth_service.dart'; -import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; +import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart'; import 'package:marco/helpers/services/device_info_service.dart'; import 'package:marco/helpers/theme/theme_customizer.dart'; import 'package:marco/helpers/theme/app_theme.dart'; @@ -28,7 +27,7 @@ Future initializeApp() async { await _handleAuthTokens(); await _setupTheme(); await _setupControllers(); - await _setupFirebaseMessaging(); + await _setupFirebaseMessaging(); _finalizeAppStyle(); @@ -47,16 +46,9 @@ Future initializeApp() async { Future _setupUI() async { setPathUrlStrategy(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - systemNavigationBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - systemNavigationBarIconBrightness: Brightness.dark, - )); - logSafe("💡 UI setup completed."); + logSafe("💡 UI setup completed with default system behavior."); } - Future _setupFirebase() async { await Firebase.initializeApp(); logSafe("💡 Firebase initialized."); @@ -126,7 +118,6 @@ Future _setupFirebaseMessaging() async { logSafe("💡 Firebase Messaging initialized."); } - void _finalizeAppStyle() { AppStyle.init(); logSafe("💡 AppStyle initialized."); diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index d12382e..3c9b02b 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -83,7 +83,7 @@ class AuthService { logSafe("Login payload (raw): $data"); logSafe("Login payload (JSON): ${jsonEncode(data)}"); - final responseData = await _post("/auth/login-mobile", data); + final responseData = await _post("/auth/app/login", data); if (responseData == null) return {"error": "Network error. Please check your connection."}; diff --git a/lib/helpers/services/permission_service.dart b/lib/helpers/services/permission_service.dart index adc1518..c3ee52e 100644 --- a/lib/helpers/services/permission_service.dart +++ b/lib/helpers/services/permission_service.dart @@ -11,19 +11,23 @@ import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; class PermissionService { + // In-memory cache keyed by user token static final Map> _userDataCache = {}; static const String _baseUrl = ApiEndpoints.baseUrl; - /// Fetches all user-related data (permissions, employee info, projects) + /// Fetches all user-related data (permissions, employee info, projects). + /// Uses in-memory cache for repeated token queries during session. static Future> fetchAllUserData( String token, { bool hasRetried = false, }) async { - logSafe("Fetching user data...", ); + logSafe("Fetching user data..."); - if (_userDataCache.containsKey(token)) { - logSafe("User data cache hit.", ); - return _userDataCache[token]!; + // Check for cached data before network request + final cached = _userDataCache[token]; + if (cached != null) { + logSafe("User data cache hit."); + return cached; } final uri = Uri.parse("$_baseUrl/user/profile"); @@ -34,8 +38,8 @@ class PermissionService { final statusCode = response.statusCode; if (statusCode == 200) { - logSafe("User data fetched successfully."); - final data = json.decode(response.body)['data']; + final raw = json.decode(response.body); + final data = raw['data'] as Map; final result = { 'permissions': _parsePermissions(data['featurePermissions']), @@ -43,10 +47,12 @@ class PermissionService { 'projects': _parseProjectsInfo(data['projects']), }; - _userDataCache[token] = result; + _userDataCache[token] = result; // Cache it for future use + logSafe("User data fetched successfully."); return result; } + // Token expired, try refresh once then redirect on failure if (statusCode == 401 && !hasRetried) { logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning); @@ -63,42 +69,43 @@ class PermissionService { throw Exception('Unauthorized. Token refresh failed.'); } - final error = json.decode(response.body)['message'] ?? 'Unknown error'; - logSafe("Failed to fetch user data: $error", level: LogLevel.warning); - throw Exception('Failed to fetch user data: $error'); + final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error'; + logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning); + throw Exception('Failed to fetch user data: $errorMsg'); } catch (e, stacktrace) { logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace); - rethrow; + rethrow; // Let the caller handle or report } } - /// Clears auth data and redirects to login + /// Handles unauthorized/user sign out flow static Future _handleUnauthorized() async { logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning); - await LocalStorage.removeToken('jwt_token'); await LocalStorage.removeToken('refresh_token'); await LocalStorage.setLoggedInUser(false); Get.offAllNamed('/auth/login-option'); } - /// Converts raw permission data into list of `UserPermission` + /// Robust model parsing for permissions static List _parsePermissions(List permissions) { logSafe("Parsing user permissions..."); return permissions - .map((id) => UserPermission.fromJson({'id': id})) + .map((perm) => UserPermission.fromJson({'id': perm})) .toList(); } - /// Converts raw employee JSON into `EmployeeInfo` - static EmployeeInfo _parseEmployeeInfo(Map data) { + /// Robust model parsing for employee info + static EmployeeInfo _parseEmployeeInfo(Map? data) { logSafe("Parsing employee info..."); + if (data == null) throw Exception("Employee data missing"); return EmployeeInfo.fromJson(data); } - /// Converts raw projects JSON into list of `ProjectInfo` - static List _parseProjectsInfo(List projects) { + /// Robust model parsing for projects list + static List _parseProjectsInfo(List? projects) { logSafe("Parsing projects info..."); + if (projects == null) return []; return projects.map((proj) => ProjectInfo.fromJson(proj)).toList(); } } diff --git a/lib/helpers/services/storage/local_storage.dart b/lib/helpers/services/storage/local_storage.dart index c91c4c4..b47dda4 100644 --- a/lib/helpers/services/storage/local_storage.dart +++ b/lib/helpers/services/storage/local_storage.dart @@ -22,6 +22,17 @@ class LocalStorage { static const String _isMpinKey = "isMpin"; static const String _fcmTokenKey = "fcm_token"; static const String _menuStorageKey = "dynamic_menus"; +// In LocalStorage + static const String _recentTenantKey = "recent_tenant_id"; + + static Future setRecentTenantId(String tenantId) => + preferences.setString(_recentTenantKey, tenantId); + + static String? getRecentTenantId() => + _initialized ? preferences.getString(_recentTenantKey) : null; + + static Future removeRecentTenantId() => + preferences.remove(_recentTenantKey); static SharedPreferences? _preferencesInstance; static bool _initialized = false; @@ -76,7 +87,8 @@ class LocalStorage { static Future removeMenus() => preferences.remove(_menuStorageKey); // ================== User Permissions ================== - static Future setUserPermissions(List permissions) async { + static Future setUserPermissions( + List permissions) async { final jsonList = permissions.map((e) => e.toJson()).toList(); return preferences.setString(_userPermissionsKey, jsonEncode(jsonList)); } @@ -94,8 +106,8 @@ class LocalStorage { preferences.remove(_userPermissionsKey); // ================== Employee Info ================== - static Future setEmployeeInfo(EmployeeInfo employeeInfo) => - preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson())); + static Future setEmployeeInfo(EmployeeInfo employeeInfo) => preferences + .setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson())); static EmployeeInfo? getEmployeeInfo() { if (!_initialized) return null; @@ -135,6 +147,7 @@ class LocalStorage { await removeMpinToken(); await removeIsMpin(); await removeMenus(); + await removeRecentTenantId(); await preferences.remove("mpin_verified"); await preferences.remove(_languageKey); await preferences.remove(_themeCustomizerKey); diff --git a/lib/helpers/services/tenant_service.dart b/lib/helpers/services/tenant_service.dart new file mode 100644 index 0000000..83acfc6 --- /dev/null +++ b/lib/helpers/services/tenant_service.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; + +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/auth_service.dart'; +import 'package:marco/model/tenant/tenant_list_model.dart'; + +/// Abstract interface for tenant service functionality +abstract class ITenantService { + Future>?> getTenants({bool hasRetried = false}); + Future selectTenant(String tenantId, {bool hasRetried = false}); +} + +/// Tenant API service +class TenantService implements ITenantService { + static const String _baseUrl = ApiEndpoints.baseUrl; + static const Map _headers = { + 'Content-Type': 'application/json', + }; + + /// Currently selected tenant + static Tenant? currentTenant; + + /// Set the selected tenant + static void setSelectedTenant(Tenant tenant) { + currentTenant = tenant; + } + + /// Check if tenant is selected + static bool get isTenantSelected => currentTenant != null; + + /// Build authorized headers + static Future> _authorizedHeaders() async { + final token = await LocalStorage.getJwtToken(); + if (token == null || token.isEmpty) { + throw Exception('Missing JWT token'); + } + return {..._headers, 'Authorization': 'Bearer $token'}; + } + + /// Handle API errors + static void _handleApiError( + http.Response response, dynamic data, String context) { + final message = data['message'] ?? 'Unknown error'; + final level = + response.statusCode >= 500 ? LogLevel.error : LogLevel.warning; + logSafe("❌ $context failed: $message [Status: ${response.statusCode}]", + level: level); + } + + /// Log exceptions + static void _logException(dynamic e, dynamic st, String context) { + logSafe("❌ $context exception", + level: LogLevel.error, error: e, stackTrace: st); + } + + @override + Future>?> getTenants( + {bool hasRetried = false}) async { + try { + final headers = await _authorizedHeaders(); + logSafe("➡️ GET $_baseUrl/auth/get/user/tenants\nHeaders: $headers", + level: LogLevel.info); + + final response = await http + .get(Uri.parse("$_baseUrl/auth/get/user/tenants"), headers: headers); + final data = jsonDecode(response.body); + + logSafe( + "⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", + level: LogLevel.info); + + if (response.statusCode == 200 && data['success'] == true) { + logSafe("✅ Tenants fetched successfully."); + return List>.from(data['data']); + } + + if (response.statusCode == 401 && !hasRetried) { + logSafe("⚠️ Unauthorized while fetching tenants. Refreshing token...", + level: LogLevel.warning); + final refreshed = await AuthService.refreshToken(); + if (refreshed) return getTenants(hasRetried: true); + logSafe("❌ Token refresh failed while fetching tenants.", + level: LogLevel.error); + return null; + } + + _handleApiError(response, data, "Fetching tenants"); + return null; + } catch (e, st) { + _logException(e, st, "Get Tenants API"); + return null; + } + } + + @override + Future selectTenant(String tenantId, {bool hasRetried = false}) async { + try { + final headers = await _authorizedHeaders(); + logSafe( + "➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers", + level: LogLevel.info); + + final response = await http.post( + Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"), + headers: headers, + ); + final data = jsonDecode(response.body); + + logSafe( + "⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]", + level: LogLevel.info); + + if (response.statusCode == 200 && data['success'] == true) { + await LocalStorage.setJwtToken(data['data']['token']); + await LocalStorage.setRefreshToken(data['data']['refreshToken']); + logSafe("✅ Tenant selected successfully. Tokens updated."); + + // 🔥 Refresh projects when tenant changes + try { + final projectController = Get.find(); + projectController.clearProjects(); + projectController.fetchProjects(); + } catch (_) { + logSafe("⚠️ ProjectController not found while refreshing projects"); + } + + return true; + } + + if (response.statusCode == 401 && !hasRetried) { + logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...", + level: LogLevel.warning); + final refreshed = await AuthService.refreshToken(); + if (refreshed) return selectTenant(tenantId, hasRetried: true); + logSafe("❌ Token refresh failed while selecting tenant.", + level: LogLevel.error); + return false; + } + + _handleApiError(response, data, "Selecting tenant"); + return false; + } catch (e, st) { + _logException(e, st, "Select Tenant API"); + return false; + } + } +} diff --git a/lib/helpers/utils/attendance_actions.dart b/lib/helpers/utils/attendance_actions.dart index 927d02c..cc67376 100644 --- a/lib/helpers/utils/attendance_actions.dart +++ b/lib/helpers/utils/attendance_actions.dart @@ -24,8 +24,8 @@ class AttendanceActionColors { ButtonActions.rejected: Colors.orange, ButtonActions.approved: Colors.green, ButtonActions.requested: Colors.yellow, - ButtonActions.approve: Colors.blueAccent, - ButtonActions.reject: Colors.pink, + ButtonActions.approve: Colors.green, + ButtonActions.reject: Colors.red, }; } diff --git a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart index 9cf0a75..4ec74b0 100644 --- a/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart +++ b/lib/helpers/widgets/dashbaord/attendance_overview_chart.dart @@ -4,7 +4,6 @@ import 'package:intl/intl.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class AttendanceDashboardChart extends StatelessWidget { AttendanceDashboardChart({Key? key}) : super(key: key); @@ -46,13 +45,9 @@ class AttendanceDashboardChart extends StatelessWidget { Color(0xFF64B5F6), // Blue 300 (repeat) ]; - static final Map _roleColorMap = {}; - Color _getRoleColor(String role) { - return _roleColorMap.putIfAbsent( - role, - () => _flatColors[_roleColorMap.length % _flatColors.length], - ); + final index = role.hashCode.abs() % _flatColors.length; + return _flatColors[index]; } @override @@ -62,12 +57,9 @@ class AttendanceDashboardChart extends StatelessWidget { return Obx(() { final isChartView = _controller.attendanceIsChartView.value; final selectedRange = _controller.attendanceSelectedRange.value; - final isLoading = _controller.isAttendanceLoading.value; final filteredData = _getFilteredData(); - if (isLoading) { - return SkeletonLoaders.buildLoadingSkeleton(); - } + return Container( decoration: _containerDecoration, @@ -106,7 +98,7 @@ class AttendanceDashboardChart extends StatelessWidget { BoxDecoration get _containerDecoration => BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.05), @@ -164,7 +156,7 @@ class _Header extends StatelessWidget { ), ), ToggleButtons( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), borderColor: Colors.grey, fillColor: Colors.blueAccent.withOpacity(0.15), selectedBorderColor: Colors.blueAccent, @@ -208,7 +200,7 @@ class _Header extends StatelessWidget { : FontWeight.normal, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), side: BorderSide( color: selectedRange == label ? Colors.blueAccent @@ -283,7 +275,7 @@ class _AttendanceChart extends StatelessWidget { height: 600, decoration: BoxDecoration( color: Colors.blueGrey.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), child: const Center( child: Text( @@ -311,7 +303,7 @@ class _AttendanceChart extends StatelessWidget { padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.blueGrey.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( tooltipBehavior: TooltipBehavior(enable: true, shared: true), @@ -387,7 +379,7 @@ class _AttendanceTable extends StatelessWidget { height: 300, decoration: BoxDecoration( color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), child: const Center( child: Text( @@ -409,7 +401,7 @@ class _AttendanceTable extends StatelessWidget { height: 300, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), color: Colors.grey.shade50, ), child: SingleChildScrollView( @@ -461,7 +453,7 @@ class _RolePill extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), ), child: MyText.labelSmall(role, fontWeight: 500), ); diff --git a/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart b/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart index 2c44271..3ced75a 100644 --- a/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart +++ b/lib/helpers/widgets/dashbaord/dashboard_overview_widgets.dart @@ -1,277 +1,393 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +// Assuming these exist in the project +import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/controller/dashboard/dashboard_controller.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; // import MyText -import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; class DashboardOverviewWidgets { static final DashboardController dashboardController = Get.find(); - static const _titleTextStyle = TextStyle( + // Text styles + static const _titleStyle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black87, + letterSpacing: 0.2, + ); + + static const _subtitleStyle = TextStyle( + fontSize: 12, + color: Colors.black54, + letterSpacing: 0.1, + ); + + static const _metricStyle = TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.black87, + ); + + static const _percentStyle = TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: Colors.black87, ); - static const _subtitleTextStyle = TextStyle( - fontSize: 14, - color: Colors.grey, - ); + static final NumberFormat _comma = NumberFormat.decimalPattern(); - static const _infoNumberTextStyle = TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ); + // Colors + static const Color _primaryA = Color(0xFF1565C0); // Blue + static const Color _accentA = Color(0xFF2E7D32); // Green + static const Color _warnA = Color(0xFFC62828); // Red + static const Color _muted = Color(0xFF9E9E9E); // Grey + static const Color _hint = Color(0xFFBDBDBD); // Light Grey + static const Color _bgSoft = Color(0xFFF7F8FA); // Light background - static const _infoNumberGreenTextStyle = TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ); - static final NumberFormat _commaFormatter = NumberFormat.decimalPattern(); - - /// Teams Overview Card without chart, labels & values in rows + // --- TEAMS OVERVIEW --- static Widget teamsOverview() { return Obx(() { if (dashboardController.isTeamsLoading.value) { - return _loadingSkeletonCard("Teams"); + return _skeletonCard(title: "Teams"); } final total = dashboardController.totalEmployees.value; - final inToday = dashboardController.inToday.value; + final inToday = dashboardController.inToday.value.clamp(0, total); + final percent = total > 0 ? inToday / total : 0.0; - return LayoutBuilder( - builder: (context, constraints) { - final cardWidth = constraints.maxWidth > 400 - ? (constraints.maxWidth / 2) - 10 - : constraints.maxWidth; + final hasData = total > 0; + final data = hasData + ? [ + _ChartData('In Today', inToday.toDouble(), _accentA), + _ChartData('Total', total.toDouble(), _muted), + ] + : [ + _ChartData('No Data', 1.0, _hint), + ]; - return SizedBox( - width: cardWidth, - child: MyCard( - borderRadiusAll: 16, - paddingAll: 20, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.group, - color: Colors.blueAccent, size: 26), - MySpacing.width(8), - MyText("Teams", style: _titleTextStyle), - ], - ), - MySpacing.height(16), - // Labels in one row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText("Total Employees", style: _subtitleTextStyle), - MyText("In Today", style: _subtitleTextStyle), - ], - ), - MySpacing.height(4), - // Values in one row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText(_commaFormatter.format(total), - style: _infoNumberTextStyle), - MyText(_commaFormatter.format(inToday), - style: _infoNumberGreenTextStyle.copyWith( - color: Colors.green[700])), - ], - ), - ], - ), - ), - ); - }, + return _MetricCard( + icon: Icons.group, + iconColor: _primaryA, + title: "Teams", + subtitle: hasData ? "Attendance today" : "Awaiting data", + chart: _SemiDonutChart( + percentLabel: "${(percent * 100).toInt()}%", + data: data, + startAngle: 270, + endAngle: 90, + showLegend: false, + ), + footer: _SingleColumnKpis( + stats: { + "In Today": _comma.format(inToday), + "Total": _comma.format(total), + }, + colors: { + "In Today": _accentA, + "Total": _muted, + }, + ), ); }); } - /// Tasks Overview Card + // --- TASKS OVERVIEW --- static Widget tasksOverview() { return Obx(() { if (dashboardController.isTasksLoading.value) { - return _loadingSkeletonCard("Tasks"); + return _skeletonCard(title: "Tasks"); } final total = dashboardController.totalTasks.value; - final completed = dashboardController.completedTasks.value; - final remaining = total - completed; - final double percent = total > 0 ? completed / total : 0.0; + final completed = + dashboardController.completedTasks.value.clamp(0, total); + final remaining = (total - completed).clamp(0, total); + final percent = total > 0 ? completed / total : 0.0; - // Task colors - const completedColor = Color(0xFF64B5F6); - const remainingColor =Color(0xFFE57373); + final hasData = total > 0; + final data = hasData + ? [ + _ChartData('Completed', completed.toDouble(), _primaryA), + _ChartData('Remaining', remaining.toDouble(), _warnA), + ] + : [ + _ChartData('No Data', 1.0, _hint), + ]; - final List<_ChartData> pieData = [ - _ChartData('Completed', completed.toDouble(), completedColor), - _ChartData('Remaining', remaining.toDouble(), remainingColor), - ]; - - return LayoutBuilder( - builder: (context, constraints) { - final cardWidth = - constraints.maxWidth < 300 ? constraints.maxWidth : 300.0; - - return SizedBox( - width: cardWidth, - child: MyCard( - borderRadiusAll: 16, - paddingAll: 20, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Icon + Title - Row( - children: [ - const Icon(Icons.task_alt, - color: completedColor, size: 26), - MySpacing.width(8), - MyText("Tasks", style: _titleTextStyle), - ], - ), - MySpacing.height(16), - - // Main Row: Bigger Pie Chart + Full-Color Info Boxes - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Pie Chart Column (Bigger) - SizedBox( - height: 140, - width: 140, - child: SfCircularChart( - annotations: [ - CircularChartAnnotation( - widget: MyText( - "${(percent * 100).toInt()}%", - style: _infoNumberGreenTextStyle.copyWith( - fontSize: 20), - ), - ), - ], - series: >[ - PieSeries<_ChartData, String>( - dataSource: pieData, - xValueMapper: (_ChartData data, _) => - data.category, - yValueMapper: (_ChartData data, _) => data.value, - pointColorMapper: (_ChartData data, _) => - data.color, - dataLabelSettings: - const DataLabelSettings(isVisible: false), - radius: '100%', - ), - ], - ), - ), - MySpacing.width(16), - - // Info Boxes Column (Full Color) - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _infoBoxFullColor( - "Completed", completed, completedColor), - MySpacing.height(8), - _infoBoxFullColor( - "Remaining", remaining, remainingColor), - ], - ), - ), - ], - ), - ], - ), - ), - ); - }, + return _MetricCard( + icon: Icons.task_alt, + iconColor: _primaryA, + title: "Tasks", + subtitle: hasData ? "Completion status" : "Awaiting data", + chart: _SemiDonutChart( + percentLabel: "${(percent * 100).toInt()}%", + data: data, + startAngle: 270, + endAngle: 90, + showLegend: false, + ), + footer: _SingleColumnKpis( + stats: { + "Completed": _comma.format(completed), + "Remaining": _comma.format(remaining), + }, + colors: { + "Completed": _primaryA, + "Remaining": _warnA, + }, + ), ); }); } - /// Full-color info box - static Widget _infoBoxFullColor(String label, int value, Color bgColor) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: bgColor, // full color - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - MyText(_commaFormatter.format(value), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - )), - MySpacing.height(2), - MyText(label, - style: const TextStyle( - fontSize: 12, - color: Colors.white, // text in white for contrast - )), - ], - ), - ); - } - - /// Loading Skeleton Card - static Widget _loadingSkeletonCard(String title) { + // Skeleton card + static Widget _skeletonCard({required String title}) { return LayoutBuilder(builder: (context, constraints) { - final cardWidth = - constraints.maxWidth < 200 ? constraints.maxWidth : 200.0; - + final width = constraints.maxWidth.clamp(220.0, 480.0); return SizedBox( - width: cardWidth, + width: width, child: MyCard( - borderRadiusAll: 16, - paddingAll: 20, + borderRadiusAll: 5, + paddingAll: 16, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _loadingBar(width: 100), + _Skeleton.line(width: 120, height: 16), MySpacing.height(12), - _loadingBar(width: 80), - MySpacing.height(12), - _loadingBar(width: double.infinity, height: 12), + _Skeleton.line(width: 80, height: 12), + MySpacing.height(16), + _Skeleton.block(height: 120), + MySpacing.height(16), + _Skeleton.line(width: double.infinity, height: 12), ], ), ), ); }); } +} - static Widget _loadingBar( - {double width = double.infinity, double height = 16}) { +// --- METRIC CARD with chart on left, stats on right --- +class _MetricCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final Widget chart; + final Widget footer; + + const _MetricCard({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.chart, + required this.footer, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + final maxW = constraints.maxWidth; + final clampedW = maxW.clamp(260.0, 560.0); + final dense = clampedW < 340; + + return SizedBox( + width: clampedW, + child: MyCard( + borderRadiusAll: 5, + paddingAll: dense ? 14 : 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: icon + title + subtitle + Row( + children: [ + _IconBadge(icon: icon, color: iconColor), + MySpacing.width(10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText(title, + style: DashboardOverviewWidgets._titleStyle), + MySpacing.height(2), + MyText(subtitle, + style: DashboardOverviewWidgets._subtitleStyle), + MySpacing.height(12), + ], + ), + ), + ], + ), + // Body: chart left, stats right + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: SizedBox( + height: dense ? 120 : 150, + child: chart, + ), + ), + MySpacing.width(12), + Expanded( + flex: 1, + child: footer, // Stats stacked vertically + ), + ], + ), + ], + ), + ), + ); + }); + } +} + +// --- SINGLE COLUMN KPIs (stacked vertically) --- +class _SingleColumnKpis extends StatelessWidget { + final Map stats; + final Map? colors; + + const _SingleColumnKpis({required this.stats, this.colors}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: stats.entries.map((entry) { + final color = colors != null && colors!.containsKey(entry.key) + ? colors![entry.key]! + : DashboardOverviewWidgets._metricStyle.color; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText(entry.key, style: DashboardOverviewWidgets._subtitleStyle), + MyText(entry.value, + style: DashboardOverviewWidgets._metricStyle + .copyWith(color: color)), + ], + ), + ); + }).toList(), + ); + } +} + +// --- SEMI DONUT CHART --- +class _SemiDonutChart extends StatelessWidget { + final String percentLabel; + final List<_ChartData> data; + final int startAngle; + final int endAngle; + final bool showLegend; + + const _SemiDonutChart({ + required this.percentLabel, + required this.data, + required this.startAngle, + required this.endAngle, + this.showLegend = false, + }); + + bool get _hasData => + data.isNotEmpty && + data.any((d) => d.color != DashboardOverviewWidgets._hint); + + @override + Widget build(BuildContext context) { + final chartData = _hasData + ? data + : [_ChartData('No Data', 1.0, DashboardOverviewWidgets._hint)]; + + return SfCircularChart( + margin: EdgeInsets.zero, + centerY: '65%', // pull donut up + legend: Legend(isVisible: showLegend && _hasData), + annotations: [ + CircularChartAnnotation( + widget: Center( + child: MyText(percentLabel, style: DashboardOverviewWidgets._percentStyle), + ), + ), + ], + series: >[ + DoughnutSeries<_ChartData, String>( + dataSource: chartData, + xValueMapper: (d, _) => d.category, + yValueMapper: (d, _) => d.value, + pointColorMapper: (d, _) => d.color, + startAngle: startAngle, + endAngle: endAngle, + radius: '80%', + innerRadius: '65%', + strokeWidth: 0, + dataLabelSettings: const DataLabelSettings(isVisible: false), + ), + ], +); + + } +} + +// --- ICON BADGE --- +class _IconBadge extends StatelessWidget { + final IconData icon; + final Color color; + + const _IconBadge({required this.icon, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: DashboardOverviewWidgets._bgSoft, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 22), + ); + } +} + +// --- SKELETON --- +class _Skeleton { + static Widget line({double width = double.infinity, double height = 14}) { return Container( - height: height, width: width, + height: height, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(6), ), ); } + + static Widget block({double height = 120}) { + return Container( + width: double.infinity, + height: height, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + ); + } } +// --- CHART DATA --- class _ChartData { final String category; final double value; final Color color; - _ChartData(this.category, this.value, this.color); } diff --git a/lib/helpers/widgets/dashbaord/project_progress_chart.dart b/lib/helpers/widgets/dashbaord/project_progress_chart.dart index 3162f11..648fc75 100644 --- a/lib/helpers/widgets/dashbaord/project_progress_chart.dart +++ b/lib/helpers/widgets/dashbaord/project_progress_chart.dart @@ -5,7 +5,6 @@ import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/controller/dashboard/dashboard_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ProjectProgressChart extends StatelessWidget { final List data; @@ -50,13 +49,9 @@ class ProjectProgressChart extends StatelessWidget { ]; static final NumberFormat _commaFormatter = NumberFormat.decimalPattern(); - static final Map _taskColorMap = {}; - Color _getTaskColor(String taskName) { - return _taskColorMap.putIfAbsent( - taskName, - () => _flatColors[_taskColorMap.length % _flatColors.length], - ); + final index = taskName.hashCode % _flatColors.length; + return _flatColors[index]; } @override @@ -66,12 +61,11 @@ class ProjectProgressChart extends StatelessWidget { return Obx(() { final isChartView = controller.projectIsChartView.value; final selectedRange = controller.projectSelectedRange.value; - final isLoading = controller.isProjectLoading.value; return Container( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.04), @@ -94,13 +88,11 @@ class ProjectProgressChart extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) => AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: isLoading - ? SkeletonLoaders.buildLoadingSkeleton() - : data.isEmpty - ? _buildNoDataMessage() - : isChartView - ? _buildChart(constraints.maxHeight) - : _buildTable(constraints.maxHeight, screenWidth), + child: data.isEmpty + ? _buildNoDataMessage() + : isChartView + ? _buildChart(constraints.maxHeight) + : _buildTable(constraints.maxHeight, screenWidth), ), ), ), @@ -129,7 +121,7 @@ class ProjectProgressChart extends StatelessWidget { ), ), ToggleButtons( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), borderColor: Colors.grey, fillColor: Colors.blueAccent.withOpacity(0.15), selectedBorderColor: Colors.blueAccent, @@ -182,7 +174,7 @@ class ProjectProgressChart extends StatelessWidget { selectedRange == label ? FontWeight.w600 : FontWeight.normal, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), side: BorderSide( color: selectedRange == label ? Colors.blueAccent @@ -206,7 +198,7 @@ class ProjectProgressChart extends StatelessWidget { padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.blueGrey.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), child: SfCartesianChart( tooltipBehavior: TooltipBehavior(enable: true), @@ -280,7 +272,7 @@ class ProjectProgressChart extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), color: Colors.grey.shade50, ), child: LayoutBuilder( @@ -332,7 +324,7 @@ class ProjectProgressChart extends StatelessWidget { height: height > 280 ? 280 : height, decoration: BoxDecoration( color: Colors.blueGrey.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), child: const Center( child: Text( diff --git a/lib/helpers/widgets/my_confirmation_dialog.dart b/lib/helpers/widgets/my_confirmation_dialog.dart index 9c9c862..28dfa60 100644 --- a/lib/helpers/widgets/my_confirmation_dialog.dart +++ b/lib/helpers/widgets/my_confirmation_dialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/widgets/my_text.dart'; - +import 'package:marco/helpers/widgets/my_snackbar.dart'; class ConfirmDialog extends StatelessWidget { final String title; @@ -115,7 +115,11 @@ class _ContentView extends StatelessWidget { Navigator.pop(context, true); // close on success } catch (e) { // Show error, dialog stays open - Get.snackbar("Error", "Failed to delete. Try again."); + showAppSnackbar( + title: "Error", + message: "Failed to delete. Try again.", + type: SnackbarType.error, + ); } finally { loading.value = false; } diff --git a/lib/helpers/widgets/tenant/organization_selector.dart b/lib/helpers/widgets/tenant/organization_selector.dart new file mode 100644 index 0000000..8295f97 --- /dev/null +++ b/lib/helpers/widgets/tenant/organization_selector.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/tenant/organization_selection_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/attendance/organization_per_project_list_model.dart'; + +class OrganizationSelector extends StatelessWidget { + final OrganizationController controller; + + /// Called whenever a new organization is selected (including "All Organizations"). + final Future Function(Organization?)? onSelectionChanged; + + /// Optional height for the selector. If null, uses default padding-based height. + final double? height; + + const OrganizationSelector({ + super.key, + required this.controller, + this.onSelectionChanged, + this.height, + }); + + Widget _popupSelector({ + required String currentValue, + required List items, + }) { + return PopupMenuButton( + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (name) async { + Organization? org = name == "All Organizations" + ? null + : controller.organizations.firstWhere((e) => e.name == name); + + controller.selectOrganization(org); + + if (onSelectionChanged != null) { + await onSelectionChanged!(org); + } + }, + itemBuilder: (context) => items + .map((e) => PopupMenuItem(value: e, child: MyText(e))) + .toList(), + child: Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + currentValue, + style: const TextStyle( + color: Colors.black87, + fontSize: 13, + height: 1.2, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoadingOrganizations.value) { + return const Center(child: CircularProgressIndicator()); + } else if (controller.organizations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: MyText.bodyMedium( + "No organizations found", + fontWeight: 500, + color: Colors.grey, + ), + ), + ); + } + + final orgNames = [ + "All Organizations", + ...controller.organizations.map((e) => e.name) + ]; + + // Listen to selectedOrganization.value + return _popupSelector( + currentValue: controller.currentSelection, + items: orgNames, + ); + }); + } +} diff --git a/lib/helpers/widgets/tenant/service_selector.dart b/lib/helpers/widgets/tenant/service_selector.dart new file mode 100644 index 0000000..61b4ad4 --- /dev/null +++ b/lib/helpers/widgets/tenant/service_selector.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/tenant/tenant_services_model.dart'; +import 'package:marco/controller/tenant/service_controller.dart'; + +class ServiceSelector extends StatelessWidget { + final ServiceController controller; + + /// Called whenever a new service is selected (including "All Services") + final Future Function(Service?)? onSelectionChanged; + + /// Optional height for the selector + final double? height; + + const ServiceSelector({ + super.key, + required this.controller, + this.onSelectionChanged, + this.height, + }); + + Widget _popupSelector({ + required String currentValue, + required List items, + }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + onSelected: items.isEmpty + ? null + : (name) async { + Service? service = name == "All Services" + ? null + : controller.services.firstWhere((e) => e.name == name); + + controller.selectService(service); + + if (onSelectionChanged != null) { + await onSelectionChanged!(service); + } + }, + itemBuilder: (context) { + if (items.isEmpty || items.length == 1 && items[0] == "All Services") { + return [ + const PopupMenuItem( + enabled: false, + child: Center( + child: Text( + "No services found", + style: TextStyle(color: Colors.grey), + ), + ), + ), + ]; + } + return items + .map((e) => PopupMenuItem(value: e, child: MyText(e))) + .toList(); + }, + child: Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + currentValue.isEmpty ? "No services found" : currentValue, + style: const TextStyle( + color: Colors.black87, + fontSize: 13, + height: 1.2, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey, size: 18), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoadingServices.value) { + return const Center(child: CircularProgressIndicator()); + } + + final serviceNames = controller.services.isEmpty + ? [] + : [ + "All Services", + ...controller.services.map((e) => e.name).toList(), + ]; + + final currentValue = + controller.services.isEmpty ? "" : controller.currentSelection; + + return _popupSelector( + currentValue: currentValue, + items: serviceNames, + ); + }); + } +} diff --git a/lib/model/attendance/attendance_log_view_model.dart b/lib/model/attendance/attendance_log_view_model.dart index 1c8ebc1..96223f6 100644 --- a/lib/model/attendance/attendance_log_view_model.dart +++ b/lib/model/attendance/attendance_log_view_model.dart @@ -1,57 +1,114 @@ import 'package:intl/intl.dart'; -class AttendanceLogViewModel { - final DateTime? activityTime; - final String? imageUrl; - final String? comment; - final String? thumbPreSignedUrl; - final String? preSignedUrl; - final String? longitude; - final String? latitude; - final int? activity; +class Employee { + final String id; + final String firstName; + final String lastName; + final String? photo; + final String jobRoleId; + final String jobRoleName; - AttendanceLogViewModel({ - this.activityTime, - this.imageUrl, - this.comment, - this.thumbPreSignedUrl, - this.preSignedUrl, - this.longitude, - this.latitude, - required this.activity, + Employee({ + required this.id, + required this.firstName, + required this.lastName, + this.photo, + required this.jobRoleId, + required this.jobRoleName, }); - factory AttendanceLogViewModel.fromJson(Map json) { - return AttendanceLogViewModel( - activityTime: json['activityTime'] != null - ? DateTime.tryParse(json['activityTime']) - : null, - imageUrl: json['imageUrl']?.toString(), - comment: json['comment']?.toString(), - thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(), - preSignedUrl: json['preSignedUrl']?.toString(), - longitude: json['longitude']?.toString(), - latitude: json['latitude']?.toString(), - activity: json['activity'] ?? 0, + factory Employee.fromJson(Map json) { + return Employee( + id: json['id'], + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo']?.toString(), + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', ); } Map toJson() { return { - 'activityTime': activityTime?.toIso8601String(), - 'imageUrl': imageUrl, + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; + } +} + +class AttendanceLogViewModel { + final String id; + final String? comment; + final Employee employee; + final DateTime? activityTime; + final int activity; + final String? photo; + final String? thumbPreSignedUrl; + final String? preSignedUrl; + final String? longitude; + final String? latitude; + final DateTime? updatedOn; + final Employee? updatedByEmployee; + final String? documentId; + + AttendanceLogViewModel({ + required this.id, + this.comment, + required this.employee, + this.activityTime, + required this.activity, + this.photo, + this.thumbPreSignedUrl, + this.preSignedUrl, + this.longitude, + this.latitude, + this.updatedOn, + this.updatedByEmployee, + this.documentId, + }); + + factory AttendanceLogViewModel.fromJson(Map json) { + return AttendanceLogViewModel( + id: json['id'], + comment: json['comment']?.toString(), + employee: Employee.fromJson(json['employee']), + activityTime: json['activityTime'] != null ? DateTime.tryParse(json['activityTime']) : null, + activity: json['activity'] ?? 0, + photo: json['photo']?.toString(), + thumbPreSignedUrl: json['thumbPreSignedUrl']?.toString(), + preSignedUrl: json['preSignedUrl']?.toString(), + longitude: json['longitude']?.toString(), + latitude: json['latitude']?.toString(), + updatedOn: json['updatedOn'] != null ? DateTime.tryParse(json['updatedOn']) : null, + updatedByEmployee: json['updatedByEmployee'] != null ? Employee.fromJson(json['updatedByEmployee']) : null, + documentId: json['documentId']?.toString(), + ); + } + + Map toJson() { + return { + 'id': id, 'comment': comment, + 'employee': employee.toJson(), + 'activityTime': activityTime?.toIso8601String(), + 'activity': activity, + 'photo': photo, 'thumbPreSignedUrl': thumbPreSignedUrl, 'preSignedUrl': preSignedUrl, 'longitude': longitude, 'latitude': latitude, - 'activity': activity, + 'updatedOn': updatedOn?.toIso8601String(), + 'updatedByEmployee': updatedByEmployee?.toJson(), + 'documentId': documentId, }; } - String? get formattedDate => activityTime != null - ? DateFormat('yyyy-MM-dd').format(activityTime!) - : null; + String? get formattedDate => + activityTime != null ? DateFormat('yyyy-MM-dd').format(activityTime!) : null; String? get formattedTime => activityTime != null ? DateFormat('hh:mm a').format(activityTime!) : null; diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 40c398e..7fc49b1 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -193,7 +193,7 @@ class _AttendanceActionButtonState extends State { controller.uploadingStates[uniqueLogKey]?.value = false; if (success) { - await controller.fetchEmployeesByProject(selectedProjectId); + await controller.fetchTodaysAttendance(selectedProjectId); await controller.fetchAttendanceLogs(selectedProjectId); await controller.fetchRegularizationLogs(selectedProjectId); await controller.fetchProjectData(selectedProjectId); diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index bbf7b2b..f9b2467 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/attendance/attendance_screen_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; class AttendanceFilterBottomSheet extends StatefulWidget { final AttendanceController controller; @@ -36,14 +37,80 @@ class _AttendanceFilterBottomSheetState String getLabelText() { final startDate = widget.controller.startDateAttendance; final endDate = widget.controller.endDateAttendance; + if (startDate != null && endDate != null) { - final start = DateFormat('dd/MM/yyyy').format(startDate); - final end = DateFormat('dd/MM/yyyy').format(endDate); + final start = + DateTimeUtils.formatDate(startDate, 'dd MMM yyyy'); + final end = DateTimeUtils.formatDate(endDate, 'dd MMM yyyy'); return "$start - $end"; } return "Date Range"; } + Widget _popupSelector({ + required String currentValue, + required List items, + required ValueChanged onSelected, + }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: onSelected, + itemBuilder: (context) => items + .map((e) => PopupMenuItem( + value: e, + child: MyText(e), + )) + .toList(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + Widget _buildOrganizationSelector(BuildContext context) { + final orgNames = [ + "All Organizations", + ...widget.controller.organizations.map((e) => e.name) + ]; + + return _popupSelector( + currentValue: + widget.controller.selectedOrganization?.name ?? "All Organizations", + items: orgNames, + onSelected: (name) { + if (name == "All Organizations") { + setState(() { + widget.controller.selectedOrganization = null; + }); + } else { + final selectedOrg = widget.controller.organizations + .firstWhere((org) => org.name == name); + setState(() { + widget.controller.selectedOrganization = selectedOrg; + }); + } + }, + ); + } + List buildMainFilters() { final hasRegularizationPermission = widget.permissionController .hasPermission(Permissions.regularizeAttendance); @@ -61,7 +128,7 @@ class _AttendanceFilterBottomSheetState final List widgets = [ Padding( - padding: EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only(bottom: 4), child: Align( alignment: Alignment.centerLeft, child: MyText.titleSmall("View", fontWeight: 600), @@ -82,11 +149,41 @@ class _AttendanceFilterBottomSheetState }), ]; + // 🔹 Organization filter + widgets.addAll([ + const Divider(), + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 12), + child: Align( + alignment: Alignment.centerLeft, + child: MyText.titleSmall("Choose Organization", fontWeight: 600), + ), + ), + Obx(() { + if (widget.controller.isLoadingOrganizations.value) { + return const Center(child: CircularProgressIndicator()); + } else if (widget.controller.organizations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: MyText.bodyMedium( + "No organizations found", + fontWeight: 500, + color: Colors.grey, + ), + ), + ); + } + return _buildOrganizationSelector(context); + }), + ]); + + // 🔹 Date Range only for attendanceLogs if (tempSelectedTab == 'attendanceLogs') { widgets.addAll([ const Divider(), Padding( - padding: EdgeInsets.only(top: 12, bottom: 4), + padding: const EdgeInsets.only(top: 12, bottom: 4), child: Align( alignment: Alignment.centerLeft, child: MyText.titleSmall("Date Range", fontWeight: 600), @@ -99,7 +196,7 @@ class _AttendanceFilterBottomSheetState context, widget.controller, ); - setState(() {}); // rebuild UI after date range is updated + setState(() {}); }, child: Ink( decoration: BoxDecoration( @@ -136,9 +233,11 @@ class _AttendanceFilterBottomSheetState borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: BaseBottomSheet( title: "Attendance Filter", + submitText: "Apply", onCancel: () => Navigator.pop(context), onSubmit: () => Navigator.pop(context, { 'selectedTab': tempSelectedTab, + 'selectedOrganization': widget.controller.selectedOrganization?.id, }), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/model/attendance/log_details_view.dart b/lib/model/attendance/log_details_view.dart index b2ad720..d39098d 100644 --- a/lib/model/attendance/log_details_view.dart +++ b/lib/model/attendance/log_details_view.dart @@ -1,18 +1,25 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; -class AttendanceLogViewButton extends StatelessWidget { +class AttendanceLogViewButton extends StatefulWidget { final dynamic employee; final dynamic attendanceController; + const AttendanceLogViewButton({ Key? key, required this.employee, required this.attendanceController, }) : super(key: key); + @override + State createState() => + _AttendanceLogViewButtonState(); +} + +class _AttendanceLogViewButtonState extends State { Future _openGoogleMaps( BuildContext context, double lat, double lon) async { final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lon'; @@ -49,7 +56,8 @@ class AttendanceLogViewButton extends StatelessWidget { } void _showLogsBottomSheet(BuildContext context) async { - await attendanceController.fetchLogsView(employee.id.toString()); + await widget.attendanceController + .fetchLogsView(widget.employee.id.toString()); showModalBottomSheet( context: context, @@ -58,157 +66,238 @@ class AttendanceLogViewButton extends StatelessWidget { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), backgroundColor: Colors.transparent, - builder: (context) => BaseBottomSheet( - title: "Attendance Log", - onCancel: () => Navigator.pop(context), - onSubmit: () => Navigator.pop(context), - showButtons: false, - child: attendanceController.attendenceLogsView.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Column( - children: const [ - Icon(Icons.info_outline, size: 40, color: Colors.grey), - SizedBox(height: 8), - Text("No attendance logs available."), - ], - ), - ) - : ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: attendanceController.attendenceLogsView.length, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (_, index) { - final log = attendanceController.attendenceLogsView[index]; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ) - ], - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context) { + Map expandedDescription = {}; + + return BaseBottomSheet( + title: "Attendance Log", + onCancel: () => Navigator.pop(context), + onSubmit: () => Navigator.pop(context), + showButtons: false, + child: widget.attendanceController.attendenceLogsView.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: [ + Icon(Icons.info_outline, size: 40, color: Colors.grey), + SizedBox(height: 8), + MyText.bodySmall("No attendance logs available."), + ], + ), + ) + : StatefulBuilder( + builder: (context, setStateSB) { + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: + widget.attendanceController.attendenceLogsView.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (_, index) { + final log = widget + .attendanceController.attendenceLogsView[index]; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ) + ], + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Icon + Date + Time + Row( children: [ - Row( - children: [ - _getLogIcon(log), - const SizedBox(width: 10), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.bodyLarge( - log.formattedDate ?? '-', - fontWeight: 600, - ), - MyText.bodySmall( - "Time: ${log.formattedTime ?? '-'}", - color: Colors.grey[700], - ), - ], - ), - ], + _getLogIcon(log), + const SizedBox(width: 12), + MyText.bodyLarge( + (log.formattedDate != null && + log.formattedDate!.isNotEmpty) + ? DateTimeUtils.convertUtcToLocal( + log.formattedDate!, + format: 'd MMM yyyy', + ) + : '-', + fontWeight: 600, ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (log.latitude != null && - log.longitude != null) - GestureDetector( - onTap: () { - final lat = double.tryParse( - log.latitude.toString()) ?? - 0.0; - final lon = double.tryParse( - log.longitude.toString()) ?? - 0.0; - if (lat >= -90 && - lat <= 90 && - lon >= -180 && - lon <= 180) { - _openGoogleMaps( - context, lat, lon); - } else { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text( - 'Invalid location coordinates')), - ); - } - }, - child: const Padding( - padding: - EdgeInsets.only(right: 8.0), - child: Icon(Icons.location_on, - size: 18, color: Colors.blue), - ), - ), - Expanded( - child: MyText.bodyMedium( - log.comment?.isNotEmpty == true - ? log.comment - : "No description provided", - fontWeight: 500, - ), - ), - ], + const SizedBox(width: 12), + MyText.bodySmall( + log.formattedTime != null + ? "Time: ${log.formattedTime}" + : "", + color: Colors.grey[700], ), ], ), - ), - const SizedBox(width: 16), - if (log.thumbPreSignedUrl != null) - GestureDetector( - onTap: () { - if (log.preSignedUrl != null) { - _showImageDialog( - context, log.preSignedUrl!); - } - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - log.thumbPreSignedUrl!, - height: 60, - width: 60, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return const Icon(Icons.broken_image, - size: 20, color: Colors.grey); - }, + const SizedBox(height: 12), + const Divider(height: 1, color: Colors.grey), + // Middle Row: Image + Text (Done by, Description & Location) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image Column + if (log.thumbPreSignedUrl != null) + GestureDetector( + onTap: () { + if (log.preSignedUrl != null) { + _showImageDialog( + context, log.preSignedUrl!); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + log.thumbPreSignedUrl!, + height: 60, + width: 60, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Icon(Icons.broken_image, + size: 40, color: Colors.grey), + ), + ), + ), + if (log.thumbPreSignedUrl != null) + const SizedBox(width: 12), + + // Text Column + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // Done by + if (log.updatedByEmployee != null) + MyText.bodySmall( + "By: ${log.updatedByEmployee!.firstName} ${log.updatedByEmployee!.lastName}", + color: Colors.grey[700], + ), + + const SizedBox(height: 8), + + // Location + if (log.latitude != null && + log.longitude != null) + Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + + GestureDetector( + onTap: () { + final lat = double.tryParse( + log.latitude + .toString()) ?? + 0.0; + final lon = double.tryParse( + log.longitude + .toString()) ?? + 0.0; + if (lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180) { + _openGoogleMaps( + context, lat, lon); + } else { + ScaffoldMessenger.of( + context) + .showSnackBar( + SnackBar( + content: MyText.bodySmall( + "Invalid location coordinates")), + ); + } + }, + child: Row( + children: [ + Icon(Icons.location_on, + size: 16, + color: Colors.blue), + SizedBox(width: 4), + MyText.bodySmall( + "View Location", + color: Colors.blue, + decoration: + TextDecoration.underline, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + + // Description with label and more/less using MyText + if (log.comment != null && + log.comment!.isNotEmpty) + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + "Description: ${log.comment!}", + maxLines: expandedDescription[ + index] == + true + ? null + : 2, + overflow: expandedDescription[ + index] == + true + ? TextOverflow.visible + : TextOverflow.ellipsis, + ), + if (log.comment!.length > 100) + GestureDetector( + onTap: () { + setStateSB(() { + expandedDescription[ + index] = + !(expandedDescription[ + index] == + true); + }); + }, + child: MyText.bodySmall( + expandedDescription[ + index] == + true + ? "less" + : "more", + color: Colors.blue, + fontWeight: 600, + ), + ), + ], + ) + else + MyText.bodySmall( + "Description: No description provided", + fontWeight: 700, + ), + ], + ), ), - ), - ) - else - const Icon(Icons.broken_image, - size: 20, color: Colors.grey), - ], - ), - ], - ), - ); - }, - ), - ), + ], + ), + ], + ), + ); + }, + ); + }, + ), + ); + }, ); } @@ -219,16 +308,16 @@ class AttendanceLogViewButton extends StatelessWidget { child: ElevatedButton( onPressed: () => _showLogsBottomSheet(context), style: ElevatedButton.styleFrom( - backgroundColor: AttendanceActionColors.colors[ButtonActions.checkIn], + backgroundColor: Colors.indigo, textStyle: const TextStyle(fontSize: 12), padding: const EdgeInsets.symmetric(horizontal: 12), ), - child: const FittedBox( + child: FittedBox( fit: BoxFit.scaleDown, - child: Text( + child: MyText.bodySmall( "View", overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: Colors.white), + color: Colors.white, ), ), ), @@ -249,7 +338,7 @@ class AttendanceLogViewButton extends StatelessWidget { final today = DateTime(now.year, now.month, now.day); final logDay = DateTime(logDate.year, logDate.month, logDate.day); - final yesterday = today.subtract(Duration(days: 1)); + final yesterday = today.subtract(const Duration(days: 1)); isTodayOrYesterday = (logDay == today) || (logDay == yesterday); } diff --git a/lib/model/attendance/organization_per_project_list_model.dart b/lib/model/attendance/organization_per_project_list_model.dart new file mode 100644 index 0000000..8149191 --- /dev/null +++ b/lib/model/attendance/organization_per_project_list_model.dart @@ -0,0 +1,106 @@ +class OrganizationListResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final String timestamp; + + OrganizationListResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory OrganizationListResponse.fromJson(Map json) { + return OrganizationListResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => Organization.fromJson(e)) + .toList() ?? + [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; + } +} + +class Organization { + final String id; + final String name; + final String email; + final String contactPerson; + final String address; + final String contactNumber; + final int sprid; + final String createdAt; + final dynamic createdBy; + final dynamic updatedBy; + final dynamic updatedAt; + final bool isActive; + + Organization({ + required this.id, + required this.name, + required this.email, + required this.contactPerson, + required this.address, + required this.contactNumber, + required this.sprid, + required this.createdAt, + this.createdBy, + this.updatedBy, + this.updatedAt, + required this.isActive, + }); + + factory Organization.fromJson(Map json) { + return Organization( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', + contactPerson: json['contactPerson'] ?? '', + address: json['address'] ?? '', + contactNumber: json['contactNumber'] ?? '', + sprid: json['sprid'] ?? 0, + createdAt: json['createdAt'] ?? '', + createdBy: json['createdBy'], + updatedBy: json['updatedBy'], + updatedAt: json['updatedAt'], + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'contactPerson': contactPerson, + 'address': address, + 'contactNumber': contactNumber, + 'sprid': sprid, + 'createdAt': createdAt, + 'createdBy': createdBy, + 'updatedBy': updatedBy, + 'updatedAt': updatedAt, + 'isActive': isActive, + }; + } +} diff --git a/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart index f3235bc..4184d49 100644 --- a/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart @@ -249,7 +249,7 @@ class _CommentTaskBottomSheetState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader("Add Comment", Icons.comment_outlined), + _buildSectionHeader("Add Note", Icons.comment_outlined), MySpacing.height(8), TextFormField( validator: diff --git a/lib/model/directory/add_comment_bottom_sheet.dart b/lib/model/directory/add_comment_bottom_sheet.dart index c208d4c..bcae2a9 100644 --- a/lib/model/directory/add_comment_bottom_sheet.dart +++ b/lib/model/directory/add_comment_bottom_sheet.dart @@ -65,7 +65,7 @@ class _AddCommentBottomSheetState extends State { ), ), MySpacing.height(12), - Center(child: MyText.titleMedium("Add Comment", fontWeight: 700)), + Center(child: MyText.titleMedium("Add Note", fontWeight: 700)), MySpacing.height(24), CommentEditorCard( controller: quillController, diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index c8ef83a..924b09d 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State { final nameCtrl = TextEditingController(); final orgCtrl = TextEditingController(); + final designationCtrl = TextEditingController(); final addrCtrl = TextEditingController(); final descCtrl = TextEditingController(); final tagCtrl = TextEditingController(); @@ -49,6 +50,7 @@ class _AddContactBottomSheetState extends State { if (c != null) { nameCtrl.text = c.name; orgCtrl.text = c.organization; + designationCtrl.text = c.designation ?? ''; addrCtrl.text = c.address; descCtrl.text = c.description; @@ -109,6 +111,7 @@ class _AddContactBottomSheetState extends State { void dispose() { nameCtrl.dispose(); orgCtrl.dispose(); + designationCtrl.dispose(); addrCtrl.dispose(); descCtrl.dispose(); tagCtrl.dispose(); @@ -118,6 +121,20 @@ class _AddContactBottomSheetState extends State { super.dispose(); } + Widget _labelWithStar(String label, {bool required = false}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + MyText.labelMedium(label), + if (required) + const Text( + " *", + style: TextStyle(color: Colors.red, fontSize: 14), + ), + ], + ); + } + InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -145,7 +162,7 @@ class _AddContactBottomSheetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.labelMedium(label), + _labelWithStar(label, required: required), MySpacing.height(8), TextFormField( controller: ctrl, @@ -386,6 +403,7 @@ class _AddContactBottomSheetState extends State { phones: phones, address: addrCtrl.text.trim(), description: descCtrl.text.trim(), + designation: designationCtrl.text.trim(), ); } @@ -412,12 +430,12 @@ class _AddContactBottomSheetState extends State { MySpacing.height(16), _textField("Organization", orgCtrl, required: true), MySpacing.height(16), - MyText.labelMedium("Select Bucket"), + _labelWithStar("Bucket", required: true), MySpacing.height(8), Stack( children: [ _popupSelector(controller.selectedBucket, controller.buckets, - "Select Bucket"), + "Choose Bucket"), Positioned( left: 0, right: 0, @@ -477,19 +495,63 @@ class _AddContactBottomSheetState extends State { icon: const Icon(Icons.add), label: const Text("Add Phone"), ), - MySpacing.height(16), - MyText.labelMedium("Category"), - MySpacing.height(8), - _popupSelector(controller.selectedCategory, - controller.categories, "Select Category"), - MySpacing.height(16), - MyText.labelMedium("Tags"), - MySpacing.height(8), - _tagInput(), - MySpacing.height(16), - _textField("Address", addrCtrl), - MySpacing.height(16), - _textField("Description", descCtrl), + Obx(() => showAdvanced.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ✅ Move Designation field here + _textField("Designation", designationCtrl), + MySpacing.height(16), + + _dynamicList( + emailCtrls, + emailLabels, + "Email", + ["Office", "Personal", "Other"], + TextInputType.emailAddress, + ), + TextButton.icon( + onPressed: () { + emailCtrls.add(TextEditingController()); + emailLabels.add("Office".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Email"), + ), + _dynamicList( + phoneCtrls, + phoneLabels, + "Phone", + ["Work", "Mobile", "Other"], + TextInputType.phone, + ), + TextButton.icon( + onPressed: () { + phoneCtrls.add(TextEditingController()); + phoneLabels.add("Work".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Phone"), + ), + MyText.labelMedium("Category"), + MySpacing.height(8), + _popupSelector( + controller.selectedCategory, + controller.categories, + "Choose Category", + ), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInput(), + MySpacing.height(16), + _textField("Address", addrCtrl), + MySpacing.height(16), + _textField("Description", descCtrl, + maxLines: 3), + ], + ) + : const SizedBox.shrink()), ], ) : const SizedBox.shrink()), diff --git a/lib/model/directory/contact_model.dart b/lib/model/directory/contact_model.dart index e03e61b..16f39cd 100644 --- a/lib/model/directory/contact_model.dart +++ b/lib/model/directory/contact_model.dart @@ -2,6 +2,7 @@ class ContactModel { final String id; final List? projectIds; final String name; + final String? designation; final List contactPhones; final List contactEmails; final ContactCategory? contactCategory; @@ -15,6 +16,7 @@ class ContactModel { required this.id, required this.projectIds, required this.name, + this.designation, required this.contactPhones, required this.contactEmails, required this.contactCategory, @@ -30,6 +32,7 @@ class ContactModel { id: json['id'], projectIds: (json['projectIds'] as List?)?.map((e) => e as String).toList(), name: json['name'], + designation: json['designation'], contactPhones: (json['contactPhones'] as List) .map((e) => ContactPhone.fromJson(e)) .toList(), @@ -48,6 +51,7 @@ class ContactModel { } } + class ContactPhone { final String id; final String label; diff --git a/lib/model/directory/note_list_response_model.dart b/lib/model/directory/note_list_response_model.dart index e92283c..9cb1f2d 100644 --- a/lib/model/directory/note_list_response_model.dart +++ b/lib/model/directory/note_list_response_model.dart @@ -79,7 +79,7 @@ class NoteModel { required this.contactId, required this.isActive, }); - NoteModel copyWith({String? note}) => NoteModel( + NoteModel copyWith({String? note, bool? isActive}) => NoteModel( id: id, note: note ?? this.note, contactName: contactName, @@ -89,7 +89,7 @@ class NoteModel { updatedAt: updatedAt, updatedBy: updatedBy, contactId: contactId, - isActive: isActive, + isActive: isActive ?? this.isActive, ); factory NoteModel.fromJson(Map json) { diff --git a/lib/model/document/document_upload_bottom_sheet.dart b/lib/model/document/document_upload_bottom_sheet.dart index 3f797dc..30170e5 100644 --- a/lib/model/document/document_upload_bottom_sheet.dart +++ b/lib/model/document/document_upload_bottom_sheet.dart @@ -393,6 +393,7 @@ class _DocumentUploadBottomSheetState extends State { validator: (value) => value == null || value.trim().isEmpty ? "Required" : null, isRequired: true, + maxLines: 3, ), ], ), @@ -564,6 +565,7 @@ class LabeledInput extends StatelessWidget { final TextEditingController controller; final String? Function(String?) validator; final bool isRequired; + final int maxLines; const LabeledInput({ Key? key, @@ -572,6 +574,7 @@ class LabeledInput extends StatelessWidget { required this.controller, required this.validator, this.isRequired = false, + this.maxLines = 1, }) : super(key: key); @override @@ -594,6 +597,7 @@ class LabeledInput extends StatelessWidget { controller: controller, validator: validator, decoration: _inputDecoration(context, hint), + maxLines: maxLines, ), ], ); diff --git a/lib/model/document/user_document_filter_bottom_sheet.dart b/lib/model/document/user_document_filter_bottom_sheet.dart index a7899fa..fa0546a 100644 --- a/lib/model/document/user_document_filter_bottom_sheet.dart +++ b/lib/model/document/user_document_filter_bottom_sheet.dart @@ -34,6 +34,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { return BaseBottomSheet( title: 'Filter Documents', + submitText: 'Apply', showButtons: hasFilters, onCancel: () => Get.back(), onSubmit: () { @@ -108,7 +109,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ), child: Center( child: MyText( - "Uploaded On", + "Upload Date", style: MyTextStyle.bodyMedium( color: docController.isUploadedAt.value @@ -139,7 +140,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { ), child: Center( child: MyText( - "Updated On", + "Update Date", style: MyTextStyle.bodyMedium( color: !docController .isUploadedAt.value @@ -165,7 +166,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { child: Obx(() { return _dateButton( label: docController.startDate.value == null - ? 'Start Date' + ? 'From Date' : DateTimeUtils.formatDate( DateTime.parse( docController.startDate.value!), @@ -191,7 +192,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { child: Obx(() { return _dateButton( label: docController.endDate.value == null - ? 'End Date' + ? 'To Date' : DateTimeUtils.formatDate( DateTime.parse( docController.endDate.value!), @@ -222,39 +223,35 @@ class UserDocumentFilterBottomSheet extends StatelessWidget { _multiSelectField( label: "Uploaded By", items: filterData.uploadedBy, - fallback: "Select Uploaded By", + fallback: "Choose Uploaded By", selectedValues: docController.selectedUploadedBy, ), _multiSelectField( label: "Category", items: filterData.documentCategory, - fallback: "Select Category", + fallback: "Choose Category", selectedValues: docController.selectedCategory, ), _multiSelectField( label: "Type", items: filterData.documentType, - fallback: "Select Type", + fallback: "Choose Type", selectedValues: docController.selectedType, ), _multiSelectField( label: "Tag", items: filterData.documentTag, - fallback: "Select Tag", + fallback: "Choose Tag", selectedValues: docController.selectedTag, ), // --- Document Status --- _buildField( - "Select Document Status", + " Document Status", Obx(() { return Container( padding: MySpacing.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), + child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index a4cc09a..82277f0 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +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/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/helpers/utils/base_bottom_sheet.dart'; -import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; class AddEmployeeBottomSheet extends StatefulWidget { final Map? employeeData; - AddEmployeeBottomSheet({this.employeeData}); + const AddEmployeeBottomSheet({super.key, this.employeeData}); @override State createState() => _AddEmployeeBottomSheetState(); @@ -22,28 +24,88 @@ class AddEmployeeBottomSheet extends StatefulWidget { class _AddEmployeeBottomSheetState extends State with UIMixin { late final AddEmployeeController _controller; + final OrganizationController _organizationController = + Get.put(OrganizationController()); + + // Local UI state + bool _hasApplicationAccess = false; + + // Local read-only controllers to avoid recreating TextEditingController in build + late final TextEditingController _orgFieldController; + late final TextEditingController _joiningDateController; + late final TextEditingController _genderController; + late final TextEditingController _roleController; @override void initState() { super.initState(); + _controller = Get.put( AddEmployeeController(), - tag: UniqueKey().toString(), + // Unique tag to avoid clashes, but stable for this widget instance + tag: UniqueKey().toString(), ); + _orgFieldController = TextEditingController(text: ''); + _joiningDateController = TextEditingController(text: ''); + _genderController = TextEditingController(text: ''); + _roleController = TextEditingController(text: ''); + + // Prefill when editing if (widget.employeeData != null) { _controller.editingEmployeeData = widget.employeeData; _controller.prefillFields(); + + final orgId = widget.employeeData!['organizationId']; + if (orgId != null) { + _controller.selectedOrganizationId = orgId; + + final selectedOrg = _organizationController.organizations + .firstWhereOrNull((o) => o.id == orgId); + if (selectedOrg != null) { + _organizationController.selectOrganization(selectedOrg); + _orgFieldController.text = selectedOrg.name; + } + } + + if (_controller.joiningDate != null) { + _joiningDateController.text = + DateFormat('dd MMM yyyy').format(_controller.joiningDate!); + } + + if (_controller.selectedGender != null) { + _genderController.text = + _controller.selectedGender!.name.capitalizeFirst ?? ''; + } + + final roleName = _controller.roles.firstWhereOrNull( + (r) => r['id'] == _controller.selectedRoleId)?['name'] ?? + ''; + _roleController.text = roleName; + } else { + _orgFieldController.text = _organizationController.currentSelection; } } + @override + void dispose() { + _orgFieldController.dispose(); + _joiningDateController.dispose(); + _genderController.dispose(); + _roleController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return GetBuilder( init: _controller, builder: (_) { + // Keep org field in sync with controller selection + _orgFieldController.text = _organizationController.currentSelection; + return BaseBottomSheet( - title: widget.employeeData != null ? "Edit Employee" : "Add Employee", + title: widget.employeeData != null ? 'Edit Employee' : 'Add Employee', onCancel: () => Navigator.pop(context), onSubmit: _handleSubmit, child: Form( @@ -51,11 +113,11 @@ class _AddEmployeeBottomSheetState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionLabel("Personal Info"), + _sectionLabel('Personal Info'), MySpacing.height(16), _inputWithIcon( - label: "First Name", - hint: "e.g., John", + label: 'First Name', + hint: 'e.g., John', icon: Icons.person, controller: _controller.basicValidator.getController('first_name')!, @@ -64,8 +126,8 @@ class _AddEmployeeBottomSheetState extends State ), MySpacing.height(16), _inputWithIcon( - label: "Last Name", - hint: "e.g., Doe", + label: 'Last Name', + hint: 'e.g., Doe', icon: Icons.person_outline, controller: _controller.basicValidator.getController('last_name')!, @@ -73,37 +135,91 @@ class _AddEmployeeBottomSheetState extends State _controller.basicValidator.getValidation('last_name'), ), MySpacing.height(16), - _sectionLabel("Joining Details"), + _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), + ), + ), + ), + ), + MySpacing.height(24), + _sectionLabel('Application Access'), + Row( + children: [ + Checkbox( + value: _hasApplicationAccess, + onChanged: (val) { + setState(() => _hasApplicationAccess = val ?? false); + }, + fillColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return Colors.indigo; + } + return Colors.white; + }), + side: WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return BorderSide.none; + } + return const BorderSide( + color: Colors.black, + width: 2, + ); + }), + checkColor: Colors.white, + ), + MyText.bodyMedium( + 'Has Application Access', + fontWeight: 600, + ), + ], + ), + MySpacing.height(8), + _buildEmailField(), + MySpacing.height(12), + _sectionLabel('Joining Details'), MySpacing.height(16), _buildDatePickerField( - label: "Joining Date", - value: _controller.joiningDate != null - ? DateFormat("dd MMM yyyy") - .format(_controller.joiningDate!) - : "", - hint: "Select Joining Date", + label: 'Joining Date', + controller: _joiningDateController, + hint: 'Select Joining Date', onTap: () => _pickJoiningDate(context), ), MySpacing.height(16), - _sectionLabel("Contact Details"), + _sectionLabel('Contact Details'), MySpacing.height(16), _buildPhoneInput(context), MySpacing.height(24), - _sectionLabel("Other Details"), + _sectionLabel('Other Details'), MySpacing.height(16), _buildDropdownField( - label: "Gender", - value: _controller.selectedGender?.name.capitalizeFirst ?? '', - hint: "Select Gender", + label: 'Gender', + controller: _genderController, + hint: 'Select Gender', onTap: () => _showGenderPopup(context), ), MySpacing.height(16), _buildDropdownField( - label: "Role", - value: _controller.roles.firstWhereOrNull((role) => - role['id'] == _controller.selectedRoleId)?['name'] ?? - "", - hint: "Select Role", + label: 'Role', + controller: _roleController, + hint: 'Select Role', onTap: () => _showRolePopup(context), ), ], @@ -114,96 +230,7 @@ class _AddEmployeeBottomSheetState extends State ); } - Widget _requiredLabel(String text) { - return Row( - children: [ - MyText.labelMedium(text), - const SizedBox(width: 4), - const Text("*", style: TextStyle(color: Colors.red)), - ], - ); - } - - Widget _buildDatePickerField({ - required String label, - required String value, - required String hint, - required VoidCallback onTap, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _requiredLabel(label), - MySpacing.height(8), - GestureDetector( - onTap: onTap, - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController(text: value), - validator: (val) { - if (val == null || val.trim().isEmpty) { - return "$label is required"; - } - return null; - }, - decoration: _inputDecoration(hint).copyWith( - suffixIcon: const Icon(Icons.calendar_today), - ), - ), - ), - ), - ], - ); - } - - Future _pickJoiningDate(BuildContext context) async { - final picked = await showDatePicker( - context: context, - initialDate: _controller.joiningDate ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - - if (picked != null) { - _controller.setJoiningDate(picked); - _controller.update(); - } - } - - Future _handleSubmit() async { - final isValid = - _controller.basicValidator.formKey.currentState?.validate() ?? false; - - if (!isValid || - _controller.joiningDate == null || - _controller.selectedGender == null || - _controller.selectedRoleId == null) { - showAppSnackbar( - title: "Missing Fields", - message: "Please complete all required fields.", - type: SnackbarType.warning, - ); - return; - } - - final result = await _controller.createOrUpdateEmployee(); - - if (result != null && result['success'] == true) { - final employeeController = Get.find(); - final projectId = employeeController.selectedProjectId; - - if (projectId == null) { - await employeeController.fetchAllEmployees(); - } else { - await employeeController.fetchEmployeesByProject(projectId); - } - - employeeController.update(['employee_screen_controller']); - - Navigator.pop(context, result['data']); - } - } + // UI Pieces Widget _sectionLabel(String title) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -214,116 +241,12 @@ class _AddEmployeeBottomSheetState extends State ], ); - Widget _inputWithIcon({ - required String label, - required String hint, - required IconData icon, - required TextEditingController controller, - required String? Function(String?)? validator, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _requiredLabel(String text) { + return Row( children: [ - _requiredLabel(label), - MySpacing.height(8), - TextFormField( - controller: controller, - validator: (val) { - if (val == null || val.trim().isEmpty) { - return "$label is required"; - } - return validator?.call(val); - }, - decoration: _inputDecoration(hint).copyWith( - prefixIcon: Icon(icon, size: 20), - ), - ), - ], - ); - } - - Widget _buildPhoneInput(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _requiredLabel("Phone Number"), - MySpacing.height(8), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), - color: Colors.grey.shade100, - ), - child: const Text("+91"), - ), - MySpacing.width(12), - Expanded( - child: TextFormField( - controller: - _controller.basicValidator.getController('phone_number'), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Phone Number is required"; - } - if (value.trim().length != 10) { - return "Phone Number must be exactly 10 digits"; - } - if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) { - return "Enter a valid 10-digit number"; - } - return null; - }, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - ], - decoration: _inputDecoration("e.g., 9876543210").copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.contacts), - onPressed: () => _controller.pickContact(context), - ), - ), - ), - ), - ], - ), - ], - ); - } - - Widget _buildDropdownField({ - required String label, - required String value, - required String hint, - required VoidCallback onTap, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _requiredLabel(label), - MySpacing.height(8), - GestureDetector( - onTap: onTap, - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController(text: value), - validator: (val) { - if (val == null || val.trim().isEmpty) { - return "$label is required"; - } - return null; - }, - decoration: _inputDecoration(hint).copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), + MyText.labelMedium(text), + const SizedBox(width: 4), + const Text('*', style: TextStyle(color: Colors.red)), ], ); } @@ -350,20 +273,298 @@ class _AddEmployeeBottomSheetState extends State ); } + Widget _inputWithIcon({ + required String label, + required String hint, + required IconData icon, + required TextEditingController controller, + required String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _requiredLabel(label), + MySpacing.height(8), + TextFormField( + controller: controller, + validator: (val) { + if (val == null || val.trim().isEmpty) { + return '$label is required'; + } + return validator?.call(val); + }, + decoration: _inputDecoration(hint).copyWith( + prefixIcon: Icon(icon, size: 20), + ), + ), + ], + ); + } + + Widget _buildEmailField() { + final emailController = _controller.basicValidator.getController('email') ?? + TextEditingController(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.labelMedium('Email'), + const SizedBox(width: 4), + if (_hasApplicationAccess) + const Text('*', style: TextStyle(color: Colors.red)), + ], + ), + MySpacing.height(8), + TextFormField( + controller: emailController, + validator: (val) { + if (_hasApplicationAccess) { + if (val == null || val.trim().isEmpty) { + return 'Email is required for application users'; + } + final email = val.trim(); + if (!RegExp(r'^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$') + .hasMatch(email)) { + return 'Enter a valid email address'; + } + } + return null; + }, + keyboardType: TextInputType.emailAddress, + decoration: _inputDecoration('e.g., john.doe@example.com').copyWith( + ), + ), + ], + ); + } + + Widget _buildDatePickerField({ + required String label, + required TextEditingController controller, + required String hint, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _requiredLabel(label), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: controller, + validator: (val) { + if (val == null || val.trim().isEmpty) { + return '$label is required'; + } + return null; + }, + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.calendar_today), + ), + ), + ), + ), + ], + ); + } + + Widget _buildDropdownField({ + required String label, + required TextEditingController controller, + required String hint, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _requiredLabel(label), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: controller, + validator: (val) { + if (val == null || val.trim().isEmpty) { + return '$label is required'; + } + return null; + }, + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); + } + + Widget _buildPhoneInput(BuildContext context) { + final phoneController = + _controller.basicValidator.getController('phone_number'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _requiredLabel('Phone Number'), + MySpacing.height(8), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + color: Colors.grey.shade100, + ), + child: const Text('+91'), + ), + MySpacing.width(12), + Expanded( + child: TextFormField( + controller: phoneController, + validator: (value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) return 'Phone Number is required'; + if (v.length != 10) + return 'Phone Number must be exactly 10 digits'; + if (!RegExp(r'^\d{10}$').hasMatch(v)) { + return 'Enter a valid 10-digit number'; + } + return null; + }, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + decoration: _inputDecoration('e.g., 9876543210').copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.contacts), + onPressed: () => _controller.pickContact(context), + ), + ), + ), + ), + ], + ), + ], + ); + } + + // Actions + + Future _pickJoiningDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: _controller.joiningDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + + if (picked != null) { + _controller.setJoiningDate(picked); + _joiningDateController.text = DateFormat('dd MMM yyyy').format(picked); + _controller.update(); + } + } + + Future _handleSubmit() async { + final isValid = + _controller.basicValidator.formKey.currentState?.validate() ?? false; + + if (!isValid || + _controller.joiningDate == null || + _controller.selectedGender == null || + _controller.selectedRoleId == null || + _organizationController.currentSelection.isEmpty || + _organizationController.currentSelection == 'All Organizations') { + showAppSnackbar( + title: 'Missing Fields', + message: 'Please complete all required fields.', + type: SnackbarType.warning, + ); + return; + } + + final result = await _controller.createOrUpdateEmployee( + email: _controller.basicValidator.getController('email')?.text.trim(), + hasApplicationAccess: _hasApplicationAccess, + ); + + if (result != null && result['success'] == true) { + final employeeController = Get.find(); + final projectId = employeeController.selectedProjectId; + + if (projectId == null) { + await employeeController.fetchAllEmployees(); + } else { + await employeeController.fetchEmployeesByProject(projectId); + } + + employeeController.update(['employee_screen_controller']); + if (mounted) Navigator.pop(context, result['data']); + } + } + + void _showOrganizationPopup(BuildContext context) async { + final orgs = _organizationController.organizations; + + if (orgs.isEmpty) { + showAppSnackbar( + title: 'No Organizations', + message: 'No organizations available to select.', + type: SnackbarType.warning, + ); + return; + } + + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: orgs + .map( + (org) => PopupMenuItem( + value: org.id, + child: Text(org.name), + ), + ) + .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(); + } + } + void _showGenderPopup(BuildContext context) async { final selected = await showMenu( context: context, position: _popupMenuPosition(context), - items: Gender.values.map((gender) { - return PopupMenuItem( - value: gender, - child: Text(gender.name.capitalizeFirst!), - ); - }).toList(), + items: Gender.values + .map( + (gender) => PopupMenuItem( + value: gender, + child: Text(gender.name.capitalizeFirst!), + ), + ) + .toList(), ); if (selected != null) { _controller.onGenderSelected(selected); + _genderController.text = selected.name.capitalizeFirst ?? ''; _controller.update(); } } @@ -372,16 +573,22 @@ class _AddEmployeeBottomSheetState extends State final selected = await showMenu( context: context, position: _popupMenuPosition(context), - items: _controller.roles.map((role) { - return PopupMenuItem( - value: role['id'], - child: Text(role['name']), - ); - }).toList(), + items: _controller.roles + .map( + (role) => PopupMenuItem( + value: role['id'], + child: Text(role['name']), + ), + ) + .toList(), ); if (selected != null) { _controller.onRoleSelected(selected); + final roleName = _controller.roles + .firstWhereOrNull((r) => r['id'] == selected)?['name'] ?? + ''; + _roleController.text = roleName; _controller.update(); } } diff --git a/lib/model/global_project_model.dart b/lib/model/global_project_model.dart index 5d1c6b5..5492d2b 100644 --- a/lib/model/global_project_model.dart +++ b/lib/model/global_project_model.dart @@ -1,51 +1,65 @@ class GlobalProjectModel { -final String id; -final String name; -final String projectAddress; -final String contactPerson; -final DateTime startDate; -final DateTime endDate; -final int teamSize; -final String projectStatusId; -final String? tenantId; + final String id; + final String name; + final String projectAddress; + final String contactPerson; + final DateTime? startDate; + final DateTime? endDate; + final int teamSize; + final String projectStatusId; + final String? tenantId; -GlobalProjectModel({ -required this.id, -required this.name, -required this.projectAddress, -required this.contactPerson, -required this.startDate, -required this.endDate, -required this.teamSize, -required this.projectStatusId, -this.tenantId, -}); + GlobalProjectModel({ + required this.id, + required this.name, + required this.projectAddress, + required this.contactPerson, + this.startDate, + this.endDate, + required this.teamSize, + required this.projectStatusId, + this.tenantId, + }); -factory GlobalProjectModel.fromJson(Map json) { -return GlobalProjectModel( -id: json['id'] ?? '', -name: json['name'] ?? '', -projectAddress: json['projectAddress'] ?? '', -contactPerson: json['contactPerson'] ?? '', -startDate: DateTime.parse(json['startDate']), -endDate: DateTime.parse(json['endDate']), -teamSize: json['teamSize'] ?? 0, // ✅ SAFER -projectStatusId: json['projectStatusId'] ?? '', -tenantId: json['tenantId'], -); + factory GlobalProjectModel.fromJson(Map json) { + return GlobalProjectModel( + id: json['id'] ?? '', + name: json['name'] ?? '', + projectAddress: json['projectAddress'] ?? '', + contactPerson: json['contactPerson'] ?? '', + startDate: _parseDate(json['startDate']), + endDate: _parseDate(json['endDate']), + teamSize: json['teamSize'] is int + ? json['teamSize'] + : int.tryParse(json['teamSize']?.toString() ?? '0') ?? 0, + projectStatusId: json['projectStatusId'] ?? '', + tenantId: json['tenantId'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'projectAddress': projectAddress, + 'contactPerson': contactPerson, + 'startDate': startDate?.toIso8601String(), + 'endDate': endDate?.toIso8601String(), + 'teamSize': teamSize, + 'projectStatusId': projectStatusId, + 'tenantId': tenantId, + }; + } + + static DateTime? _parseDate(dynamic value) { + if (value == null || value.toString().trim().isEmpty) { + return null; + } + try { + return DateTime.parse(value.toString()); + } catch (e) { + print('⚠️ Failed to parse date "$value": $e'); + return null; + } + } } - -Map toJson() { -return { -'id': id, -'name': name, -'projectAddress': projectAddress, -'contactPerson': contactPerson, -'startDate': startDate.toIso8601String(), -'endDate': endDate.toIso8601String(), -'teamSize': teamSize, -'projectStatusId': projectStatusId, -'tenantId': tenantId, -}; -} -} \ No newline at end of file diff --git a/lib/model/project_model.dart b/lib/model/project_model.dart index 9498cf4..1e54951 100644 --- a/lib/model/project_model.dart +++ b/lib/model/project_model.dart @@ -3,8 +3,8 @@ class ProjectModel { final String name; final String projectAddress; final String contactPerson; - final DateTime startDate; - final DateTime endDate; + final DateTime? startDate; + final DateTime? endDate; final int teamSize; final double completedWork; final double plannedWork; @@ -16,8 +16,8 @@ class ProjectModel { required this.name, required this.projectAddress, required this.contactPerson, - required this.startDate, - required this.endDate, + this.startDate, + this.endDate, required this.teamSize, required this.completedWork, required this.plannedWork, @@ -25,36 +25,30 @@ class ProjectModel { this.tenantId, }); - // Factory method to create an instance of ProjectModel from a JSON object factory ProjectModel.fromJson(Map json) { return ProjectModel( - id: json['id'], - name: json['name'], - projectAddress: json['projectAddress'], - contactPerson: json['contactPerson'], - startDate: DateTime.parse(json['startDate']), - endDate: DateTime.parse(json['endDate']), - teamSize: json['teamSize'], - completedWork: json['completedWork'] != null - ? (json['completedWork'] as num).toDouble() - : 0.0, - plannedWork: json['plannedWork'] != null - ? (json['plannedWork'] as num).toDouble() - : 0.0, - projectStatusId: json['projectStatusId'], - tenantId: json['tenantId'], + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + projectAddress: json['projectAddress']?.toString() ?? '', + contactPerson: json['contactPerson']?.toString() ?? '', + startDate: _parseDate(json['startDate']), + endDate: _parseDate(json['endDate']), + teamSize: _parseInt(json['teamSize']), + completedWork: _parseDouble(json['completedWork']), + plannedWork: _parseDouble(json['plannedWork']), + projectStatusId: json['projectStatusId']?.toString() ?? '', + tenantId: json['tenantId']?.toString(), ); } - // Method to convert the ProjectModel instance back to a JSON object Map toJson() { return { 'id': id, 'name': name, 'projectAddress': projectAddress, 'contactPerson': contactPerson, - 'startDate': startDate.toIso8601String(), - 'endDate': endDate.toIso8601String(), + 'startDate': startDate?.toIso8601String(), + 'endDate': endDate?.toIso8601String(), 'teamSize': teamSize, 'completedWork': completedWork, 'plannedWork': plannedWork, @@ -62,4 +56,30 @@ class ProjectModel { 'tenantId': tenantId, }; } + + // ---------- Helpers ---------- + + static DateTime? _parseDate(dynamic value) { + if (value == null || value.toString().trim().isEmpty) { + return null; + } + try { + return DateTime.parse(value.toString()); + } catch (e) { + print('⚠️ Failed to parse date: $value'); + return null; + } + } + + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + return int.tryParse(value.toString()) ?? 0; + } + + static double _parseDouble(dynamic value) { + if (value == null) return 0.0; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()) ?? 0.0; + } } diff --git a/lib/model/tenant/tenant_list_model.dart b/lib/model/tenant/tenant_list_model.dart new file mode 100644 index 0000000..34de63b --- /dev/null +++ b/lib/model/tenant/tenant_list_model.dart @@ -0,0 +1,109 @@ +class Tenant { + final String id; + final String name; + final String email; + final String? domainName; + final String contactName; + final String contactNumber; + final String? logoImage; + final String? organizationSize; + final Industry? industry; + final TenantStatus? tenantStatus; + + Tenant({ + required this.id, + required this.name, + required this.email, + this.domainName, + required this.contactName, + required this.contactNumber, + this.logoImage, + this.organizationSize, + this.industry, + this.tenantStatus, + }); + + factory Tenant.fromJson(Map json) { + return Tenant( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'] ?? '', + domainName: json['domainName'] as String?, + contactName: json['contactName'] ?? '', + contactNumber: json['contactNumber'] ?? '', + logoImage: json['logoImage'] is String ? json['logoImage'] : null, + organizationSize: json['organizationSize'] is String + ? json['organizationSize'] + : null, + industry: json['industry'] != null + ? Industry.fromJson(json['industry']) + : null, + tenantStatus: json['tenantStatus'] != null + ? TenantStatus.fromJson(json['tenantStatus']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'domainName': domainName, + 'contactName': contactName, + 'contactNumber': contactNumber, + 'logoImage': logoImage, + 'organizationSize': organizationSize, + 'industry': industry?.toJson(), + 'tenantStatus': tenantStatus?.toJson(), + }; + } +} + +class Industry { + final String id; + final String name; + + Industry({ + required this.id, + required this.name, + }); + + factory Industry.fromJson(Map json) { + return Industry( + id: json['id'] ?? '', + name: json['name'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + }; + } +} + +class TenantStatus { + final String id; + final String name; + + TenantStatus({ + required this.id, + required this.name, + }); + + factory TenantStatus.fromJson(Map json) { + return TenantStatus( + id: json['id'] ?? '', + name: json['name'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + }; + } +} diff --git a/lib/model/tenant/tenant_services_model.dart b/lib/model/tenant/tenant_services_model.dart new file mode 100644 index 0000000..2416e38 --- /dev/null +++ b/lib/model/tenant/tenant_services_model.dart @@ -0,0 +1,78 @@ +class ServiceListResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final String timestamp; + + ServiceListResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ServiceListResponse.fromJson(Map json) { + return ServiceListResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => Service.fromJson(e)) + .toList() ?? + [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; + } +} + +class Service { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + Service({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory Service.fromJson(Map json) { + return Service( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'isSystem': isSystem, + 'isActive': isActive, + }; + } +} diff --git a/lib/routes.dart b/lib/routes.dart index 70bd46d..a2f9362 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/services/auth_service.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; import 'package:marco/view/auth/forgot_password_screen.dart'; import 'package:marco/view/auth/login_screen.dart'; import 'package:marco/view/auth/register_account_screen.dart'; @@ -19,13 +20,21 @@ import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/document/user_document_screen.dart'; +import 'package:marco/view/tenant/tenant_selection_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { - return AuthService.isLoggedIn - ? null - : RouteSettings(name: '/auth/login-option'); + if (!AuthService.isLoggedIn) { + if (route != '/auth/login-option') { + return const RouteSettings(name: '/auth/login-option'); + } + } else if (!TenantService.isTenantSelected) { + if (route != '/select-tenant') { + return const RouteSettings(name: '/select-tenant'); + } + } + return null; } } @@ -40,6 +49,10 @@ getPageRoute() { page: () => DashboardScreen(), // or your actual home screen middlewares: [AuthMiddleware()], ), + GetPage( + name: '/select-tenant', + page: () => const TenantSelectionScreen(), + middlewares: [AuthMiddleware()]), // Dashboard GetPage( @@ -67,12 +80,12 @@ getPageRoute() { name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]), - // Expense + // Expense GetPage( name: '/dashboard/expense-main-page', page: () => ExpenseMainScreen(), middlewares: [AuthMiddleware()]), - // Documents + // Documents GetPage( name: '/dashboard/document-main-page', page: () => UserDocumentsPage(), diff --git a/lib/view/Attendence/attendance_logs_tab.dart b/lib/view/Attendence/attendance_logs_tab.dart index 852e851..fb9e5d0 100644 --- a/lib/view/Attendence/attendance_logs_tab.dart +++ b/lib/view/Attendence/attendance_logs_tab.dart @@ -11,7 +11,6 @@ import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/attendance/log_details_view.dart'; import 'package:marco/model/attendance/attendence_action_button.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; -import 'package:marco/helpers/services/app_logger.dart'; class AttendanceLogsTab extends StatefulWidget { final AttendanceController controller; @@ -94,16 +93,6 @@ class _AttendanceLogsTabState extends State { } else { priority = 5; } - - // ✅ Use AppLogger instead of print - logSafe( - "[AttendanceLogs] Priority calculated " - "name=${employee.name}, activity=${employee.activity}, " - "checkIn=${employee.checkIn}, checkOut=${employee.checkOut}, " - "buttonText=$text, priority=$priority", - level: LogLevel.debug, - ); - return priority; } diff --git a/lib/view/Attendence/attendance_screen.dart b/lib/view/Attendence/attendance_screen.dart index c79b20c..32d3d8d 100644 --- a/lib/view/Attendence/attendance_screen.dart +++ b/lib/view/Attendence/attendance_screen.dart @@ -47,6 +47,7 @@ class _AttendanceScreenState extends State with UIMixin { Future _loadData(String projectId) async { try { + attendanceController.selectedTab = 'todaysAttendance'; await attendanceController.loadAttendanceData(projectId); attendanceController.update(['attendance_dashboard_controller']); } catch (e) { @@ -56,7 +57,24 @@ class _AttendanceScreenState extends State with UIMixin { Future _refreshData() async { final projectId = projectController.selectedProjectId.value; - if (projectId.isNotEmpty) await _loadData(projectId); + if (projectId.isEmpty) return; + + // Call only the relevant API for current tab + switch (selectedTab) { + case 'todaysAttendance': + await attendanceController.fetchTodaysAttendance(projectId); + break; + case 'attendanceLogs': + await attendanceController.fetchAttendanceLogs( + projectId, + dateFrom: attendanceController.startDateAttendance, + dateTo: attendanceController.endDateAttendance, + ); + break; + case 'regularizationRequests': + await attendanceController.fetchRegularizationLogs(projectId); + break; + } } Widget _buildAppBar() { @@ -195,15 +213,26 @@ class _AttendanceScreenState extends State with UIMixin { final selectedProjectId = projectController.selectedProjectId.value; final selectedView = result['selectedTab'] as String?; + final selectedOrgId = + result['selectedOrganization'] as String?; + + if (selectedOrgId != null) { + attendanceController.selectedOrganization = + attendanceController.organizations + .firstWhere((o) => o.id == selectedOrgId); + } if (selectedProjectId.isNotEmpty) { try { - await attendanceController - .fetchEmployeesByProject(selectedProjectId); - await attendanceController - .fetchAttendanceLogs(selectedProjectId); - await attendanceController - .fetchRegularizationLogs(selectedProjectId); + await attendanceController.fetchTodaysAttendance( + selectedProjectId, + ); + await attendanceController.fetchAttendanceLogs( + selectedProjectId, + ); + await attendanceController.fetchRegularizationLogs( + selectedProjectId, + ); await attendanceController .fetchProjectData(selectedProjectId); } catch (_) {} @@ -214,6 +243,11 @@ class _AttendanceScreenState extends State with UIMixin { if (selectedView != null && selectedView != selectedTab) { setState(() => selectedTab = selectedView); + attendanceController.selectedTab = selectedView; + if (selectedProjectId.isNotEmpty) { + await attendanceController + .fetchProjectData(selectedProjectId); + } } } }, diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index c79ea61..5f89aec 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -13,7 +13,6 @@ import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart'; import 'package:marco/view/layouts/layout.dart'; import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart'; class DashboardScreen extends StatefulWidget { @@ -85,12 +84,7 @@ class _DashboardScreenState extends State with UIMixin { /// Project Progress Chart Section Widget _buildProjectProgressChartSection() { return Obx(() { - if (dashboardController.isProjectLoading.value) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: SkeletonLoaders.chartSkeletonLoader(), - ); - } + if (dashboardController.projectChartData.isEmpty) { return const Padding( @@ -102,7 +96,7 @@ class _DashboardScreenState extends State with UIMixin { } return ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), child: SizedBox( height: 400, child: ProjectProgressChart( @@ -116,14 +110,6 @@ class _DashboardScreenState extends State with UIMixin { /// Attendance Chart Section Widget _buildAttendanceChartSection() { return Obx(() { - if (menuController.isLoading.value) { - // ✅ Show Skeleton Loader Instead of CircularProgressIndicator - return Padding( - padding: const EdgeInsets.all(8.0), - child: SkeletonLoaders - .chartSkeletonLoader(), // <-- using the skeleton we built - ); - } final isAttendanceAllowed = menuController.isMenuAllowed("Attendance"); @@ -141,7 +127,7 @@ class _DashboardScreenState extends State with UIMixin { child: IgnorePointer( ignoring: !isProjectSelected, child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), child: SizedBox( height: 400, child: AttendanceDashboardChart(), @@ -198,7 +184,7 @@ class _DashboardScreenState extends State with UIMixin { width: width, height: 100, paddingAll: 5, - borderRadiusAll: 10, + borderRadiusAll: 5, border: Border.all(color: Colors.grey.withOpacity(0.15)), child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -304,12 +290,12 @@ class _DashboardScreenState extends State with UIMixin { ignoring: !isEnabled, child: InkWell( onTap: () => _handleStatCardTap(statItem, isEnabled), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), child: MyCard.bordered( width: width, height: cardHeight, paddingAll: 4, - borderRadiusAll: 6, + borderRadiusAll: 5, border: Border.all(color: Colors.grey.withOpacity(0.15)), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index b00856b..e77c747 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -16,6 +16,7 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; // HELPER: Delta to HTML conversion String _convertDeltaToHtml(dynamic delta) { @@ -81,8 +82,11 @@ class _ContactDetailScreenState extends State { projectController = Get.find(); contactRx = widget.contact.obs; - WidgetsBinding.instance.addPostFrameCallback((_) { - directoryController.fetchCommentsForContact(contactRx.value.id); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await directoryController.fetchCommentsForContact(contactRx.value.id, + active: true); + await directoryController.fetchCommentsForContact(contactRx.value.id, + active: false); }); // Listen to controller's allContacts and update contact if changed @@ -169,10 +173,10 @@ class _ContactDetailScreenState extends State { children: [ Row(children: [ Avatar( - firstName: firstName, - lastName: lastName, - size: 35, - backgroundColor: Colors.indigo), + firstName: firstName, + lastName: lastName, + size: 35, + ), MySpacing.width(12), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -198,7 +202,7 @@ class _ContactDetailScreenState extends State { ), tabs: const [ Tab(text: "Details"), - Tab(text: "Comments"), + Tab(text: "Notes"), ], ), ], @@ -340,51 +344,48 @@ class _ContactDetailScreenState extends State { Widget _buildCommentsTab() { return Obx(() { final contactId = contactRx.value.id; - if (!directoryController.contactCommentsMap.containsKey(contactId)) { - return const Center(child: CircularProgressIndicator()); - } - final comments = directoryController + // Get active and inactive comments + final activeComments = directoryController .getCommentsForContact(contactId) - .reversed + .where((c) => c.isActive) .toList(); + final inactiveComments = directoryController + .getCommentsForContact(contactId) + .where((c) => !c.isActive) + .toList(); + + // Combine both and keep the same sorting (recent first) + final comments = + [...activeComments, ...inactiveComments].reversed.toList(); final editingId = directoryController.editingCommentId.value; + if (comments.isEmpty) { + return Center( + child: MyText.bodyLarge("No notes yet.", color: Colors.grey), + ); + } + return Stack( children: [ MyRefreshIndicator( onRefresh: () async { - await directoryController.fetchCommentsForContact(contactId); + await directoryController.fetchCommentsForContact(contactId, + active: true); + await directoryController.fetchCommentsForContact(contactId, + active: false); }, - child: comments.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: Get.height * 0.6, - child: Center( - child: MyText.bodyLarge( - "No comments yet.", - color: Colors.grey, - ), - ), - ), - ], - ) - : Padding( - padding: MySpacing.xy(12, 12), - child: ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 100), - itemCount: comments.length, - separatorBuilder: (_, __) => MySpacing.height(14), - itemBuilder: (_, index) => _buildCommentItem( - comments[index], - editingId, - contactId, - ), - ), - ), + child: Padding( + padding: MySpacing.xy(12, 12), + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 100), + itemCount: comments.length, + separatorBuilder: (_, __) => MySpacing.height(14), + itemBuilder: (_, index) => + _buildCommentItem(comments[index], editingId, contactId), + ), + ), ), if (editingId == null) Positioned( @@ -398,15 +399,15 @@ class _ContactDetailScreenState extends State { isScrollControlled: true, ); if (result == true) { - await directoryController - .fetchCommentsForContact(contactId); + await directoryController.fetchCommentsForContact(contactId, + active: true); + await directoryController.fetchCommentsForContact(contactId, + active: false); } }, icon: const Icon(Icons.add_comment, color: Colors.white), - label: const Text( - "Add Comment", - style: TextStyle(color: Colors.white), - ), + label: const Text("Add Note", + style: TextStyle(color: Colors.white)), ), ), ], @@ -419,6 +420,7 @@ class _ContactDetailScreenState extends State { final initials = comment.createdBy.firstName.isNotEmpty ? comment.createdBy.firstName[0].toUpperCase() : "?"; + final decodedDelta = HtmlToDelta().convert(comment.note); final quillController = isEditing ? quill.QuillController( @@ -427,58 +429,144 @@ class _ContactDetailScreenState extends State { ) : null; - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: MySpacing.xy(8, 7), + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isEditing ? Colors.indigo[50] : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isEditing ? Colors.indigo : Colors.grey.shade300, - width: 1.2, - ), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2)) + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 🧑 Header Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Avatar(firstName: initials, lastName: '', size: 36), - MySpacing.width(12), + Avatar( + firstName: initials, + lastName: '', + size: 40, + ), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodyMedium("By: ${comment.createdBy.firstName}", - fontWeight: 600, color: Colors.indigo[800]), - MySpacing.height(4), - MyText.bodySmall( + // Full name on top + Text( + "${comment.createdBy.firstName} ${comment.createdBy.lastName}", + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // Job Role + if (comment.createdBy.jobRoleName?.isNotEmpty ?? false) + Text( + comment.createdBy.jobRoleName, + style: TextStyle( + fontSize: 13, + color: Colors.indigo[600], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + // Timestamp + Text( DateTimeUtils.convertUtcToLocal( comment.createdAt.toString(), format: 'dd MMM yyyy, hh:mm a', ), - color: Colors.grey[600], + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), ), ], ), ), - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.indigo, - ), - onPressed: () { - directoryController.editingCommentId.value = - isEditing ? null : comment.id; - }, + + // ⚡ Action buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!comment.isActive) + IconButton( + icon: const Icon(Icons.restore, + size: 18, color: Colors.green), + tooltip: "Restore", + splashRadius: 18, + onPressed: () async { + await Get.dialog( + ConfirmDialog( + title: "Restore Note", + message: + "Are you sure you want to restore this note?", + confirmText: "Restore", + confirmColor: Colors.green, + icon: Icons.restore, + onConfirm: () async { + await directoryController.restoreComment( + comment.id, contactId); + }, + ), + ); + }, + ), + if (comment.isActive) ...[ + IconButton( + icon: const Icon(Icons.edit_outlined, + size: 18, color: Colors.indigo), + tooltip: "Edit", + splashRadius: 18, + onPressed: () { + directoryController.editingCommentId.value = + isEditing ? null : comment.id; + }, + ), + IconButton( + icon: const Icon(Icons.delete_outline, + size: 18, color: Colors.red), + tooltip: "Delete", + splashRadius: 18, + onPressed: () async { + await Get.dialog( + ConfirmDialog( + title: "Delete Note", + message: + "Are you sure you want to delete this note?", + confirmText: "Delete", + confirmColor: Colors.red, + icon: Icons.delete_forever, + onConfirm: () async { + await directoryController.deleteComment( + comment.id, contactId); + }, + ), + ); + }, + ), + ], + ], ), ], ), + + const SizedBox(height: 8), + + // 📝 Comment Content if (isEditing && quillController != null) CommentEditorCard( controller: quillController, @@ -499,7 +587,15 @@ class _ContactDetailScreenState extends State { "body": html.Style( margin: html.Margins.zero, padding: html.HtmlPaddings.zero, - fontSize: html.FontSize.medium, + fontSize: html.FontSize(14), + color: Colors.black87, + ), + "p": html.Style( + margin: html.Margins.only(bottom: 6), + lineHeight: const html.LineHeight(1.4), + ), + "strong": html.Style( + fontWeight: FontWeight.w700, color: Colors.black87, ), }, diff --git a/lib/view/directory/directory_main_screen.dart b/lib/view/directory/directory_main_screen.dart index b8b1fdc..c5d55a1 100644 --- a/lib/view/directory/directory_main_screen.dart +++ b/lib/view/directory/directory_main_screen.dart @@ -10,16 +10,36 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/view/directory/directory_view.dart'; import 'package:marco/view/directory/notes_view.dart'; -class DirectoryMainScreen extends StatelessWidget { - DirectoryMainScreen({super.key}); +class DirectoryMainScreen extends StatefulWidget { + const DirectoryMainScreen({super.key}); + + @override + State createState() => _DirectoryMainScreenState(); +} + +class _DirectoryMainScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; final DirectoryController controller = Get.put(DirectoryController()); final NotesController notesController = Get.put(NotesController()); + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF5F5F5), appBar: PreferredSize( preferredSize: const Size.fromHeight(72), child: AppBar( @@ -79,116 +99,34 @@ class DirectoryMainScreen extends StatelessWidget { ), ), ), - body: SafeArea( - child: Column( - children: [ - // Toggle between Directory and Notes - Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - final isNotesView = controller.isNotesView.value; - - return Container( - padding: EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => controller.isNotesView.value = false, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: !isNotesView - ? Colors.red - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.contacts, - size: 16, - color: !isNotesView - ? Colors.white - : Colors.grey), - const SizedBox(width: 6), - Text( - 'Directory', - style: TextStyle( - color: !isNotesView - ? Colors.white - : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: GestureDetector( - onTap: () => controller.isNotesView.value = true, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: - isNotesView ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.notes, - size: 16, - color: isNotesView - ? Colors.white - : Colors.grey), - const SizedBox(width: 6), - Text( - 'Notes', - style: TextStyle( - color: isNotesView - ? Colors.white - : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - }), + body: Column( + children: [ + // ---------------- TabBar ---------------- + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.red, + tabs: const [ + Tab(text: "Directory"), + Tab(text: "Notes"), + ], ), + ), - // Main View - Expanded( - child: Obx(() => - controller.isNotesView.value ? NotesView() : DirectoryView()), + // ---------------- TabBarView ---------------- + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + DirectoryView(), + NotesView(), + ], ), - ], - ), + ), + ], ), ); } diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 35fca78..d1e6647 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -144,15 +144,38 @@ class _DirectoryViewState extends State { ); } + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'No matching contacts found.', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'Try adjusting your filters or refresh to reload.', + color: Colors.grey, + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.white, - floatingActionButton: FloatingActionButton( + backgroundColor: Colors.grey[100], + floatingActionButton: FloatingActionButton.extended( heroTag: 'createContact', backgroundColor: Colors.red, onPressed: _handleCreateContact, - child: const Icon(Icons.person_add_alt_1, color: Colors.white), + icon: const Icon(Icons.person_add_alt_1, color: Colors.white), + label: const Text("Add Contact", style: TextStyle(color: Colors.white)), ), body: Column( children: [ @@ -195,11 +218,11 @@ class _DirectoryViewState extends State { filled: true, fillColor: Colors.white, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), ), @@ -217,7 +240,7 @@ class _DirectoryViewState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), child: IconButton( icon: Icon(Icons.tune, @@ -262,14 +285,14 @@ class _DirectoryViewState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), child: PopupMenuButton( padding: EdgeInsets.zero, icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), itemBuilder: (context) { List> menuItems = []; @@ -375,7 +398,7 @@ class _DirectoryViewState extends State { const Icon(Icons.visibility_off_outlined, size: 20, color: Colors.black87), const SizedBox(width: 10), - const Expanded(child: Text('Show Inactive')), + const Expanded(child: Text('Show Deleted Contacts')), Switch.adaptive( value: !controller.isActive.value, activeColor: Colors.indigo, @@ -412,27 +435,7 @@ class _DirectoryViewState extends State { SkeletonLoaders.contactSkeletonCard(), ) : controller.filteredContacts.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: - MediaQuery.of(context).size.height * 0.6, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.contact_page_outlined, - size: 60, color: Colors.grey), - const SizedBox(height: 12), - MyText.bodyMedium('No contacts found.', - fontWeight: 500), - ], - ), - ), - ), - ], - ) + ? _buildEmptyState() : ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: MySpacing.only( diff --git a/lib/view/directory/notes_view.dart b/lib/view/directory/notes_view.dart index 9f6829f..501bab2 100644 --- a/lib/view/directory/notes_view.dart +++ b/lib/view/directory/notes_view.dart @@ -11,6 +11,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/Directory/comment_editor_card.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; class NotesView extends StatelessWidget { final NotesController controller = Get.find(); @@ -71,6 +72,28 @@ class NotesView extends StatelessWidget { return buffer.toString(); } + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey), + MySpacing.height(18), + MyText.titleMedium( + 'No matching notes found.', + fontWeight: 600, + color: Colors.grey, + ), + MySpacing.height(10), + MyText.bodySmall( + 'Try adjusting your filters or refresh to reload.', + color: Colors.grey, + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Column( @@ -94,17 +117,17 @@ class NotesView extends StatelessWidget { filled: true, fillColor: Colors.white, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), ), ), ), - ), + ), ], ), ), @@ -121,25 +144,19 @@ class NotesView extends StatelessWidget { if (notes.isEmpty) { return MyRefreshIndicator( onRefresh: _refreshNotes, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.6, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.note_alt_outlined, - size: 60, color: Colors.grey), - const SizedBox(height: 12), - MyText.bodyMedium('No notes found.', - fontWeight: 500), - ], + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: _buildEmptyState(), ), ), - ), - ], + ); + }, ), ); } @@ -193,7 +210,7 @@ class NotesView extends StatelessWidget { isEditing ? Colors.indigo : Colors.grey.shade300, width: 1.1, ), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), boxShadow: const [ BoxShadow( color: Colors.black12, @@ -228,17 +245,83 @@ class NotesView extends StatelessWidget { ], ), ), - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - color: Colors.indigo, - size: 20, + + /// Edit / Delete / Restore Icons + if (!note.isActive) + IconButton( + icon: const Icon(Icons.restore, + color: Colors.green, size: 20), + tooltip: "Restore", + padding: EdgeInsets + .zero, + onPressed: () async { + await Get.dialog( + ConfirmDialog( + title: "Restore Note", + message: + "Are you sure you want to restore this note?", + confirmText: "Restore", + confirmColor: Colors.green, + icon: Icons.restore, + onConfirm: () async { + await controller.restoreOrDeleteNote( + note, + restore: true); + }, + ), + 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, + ); + }, + ), + ], ), - onPressed: () { - controller.editingNoteId.value = - isEditing ? null : note.id; - }, - ), ], ), diff --git a/lib/view/document/document_details_page.dart b/lib/view/document/document_details_page.dart index d84481a..e20953b 100644 --- a/lib/view/document/document_details_page.dart +++ b/lib/view/document/document_details_page.dart @@ -109,7 +109,7 @@ class _DocumentDetailsPageState extends State { padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), @@ -191,7 +191,7 @@ class _DocumentDetailsPageState extends State { isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: - BorderRadius.vertical(top: Radius.circular(20)), + BorderRadius.vertical(top: Radius.circular(5)), ), builder: (_) { return DocumentEditBottomSheet( @@ -247,7 +247,7 @@ class _DocumentDetailsPageState extends State { style: ElevatedButton.styleFrom( backgroundColor: Colors.green, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric(vertical: 8), ), @@ -281,7 +281,7 @@ class _DocumentDetailsPageState extends State { style: ElevatedButton.styleFrom( backgroundColor: Colors.red, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric(vertical: 8), ), @@ -378,7 +378,7 @@ class _DocumentDetailsPageState extends State { margin: const EdgeInsets.only(right: 6, bottom: 6), decoration: BoxDecoration( color: Colors.blue.shade100, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( label, diff --git a/lib/view/document/user_document_screen.dart b/lib/view/document/user_document_screen.dart index caafcf3..a60c73e 100644 --- a/lib/view/document/user_document_screen.dart +++ b/lib/view/document/user_document_screen.dart @@ -67,7 +67,7 @@ class _UserDocumentsPageState extends State { super.dispose(); } - Widget _buildDocumentTile(DocumentItem doc) { + Widget _buildDocumentTile(DocumentItem doc, bool showDateHeader) { final uploadDate = DateFormat("dd MMM yyyy").format(doc.uploadedAt.toLocal()); @@ -79,15 +79,16 @@ class _UserDocumentsPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: MyText.bodySmall( - uploadDate, - fontSize: 13, - fontWeight: 500, - color: Colors.grey, + if (showDateHeader) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: MyText.bodySmall( + uploadDate, + fontSize: 13, + fontWeight: 500, + color: Colors.grey, + ), ), - ), InkWell( onTap: () { // 👉 Navigate to details page @@ -98,7 +99,7 @@ class _UserDocumentsPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), @@ -114,7 +115,7 @@ class _UserDocumentsPageState extends State { padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), child: const Icon(Icons.description, color: Colors.blue), ), @@ -190,7 +191,7 @@ class _UserDocumentsPageState extends State { if (result == true) { debugPrint("✅ Document deleted and removed from list"); } - } else if (value == "activate") { + } else if (value == "restore") { // existing activate flow (unchanged) final success = await docController.toggleDocumentActive( doc.id, @@ -201,14 +202,14 @@ class _UserDocumentsPageState extends State { if (success) { showAppSnackbar( - title: "Reactivated", - message: "Document reactivated successfully", + title: "Restored", + message: "Document reastored successfully", type: SnackbarType.success, ); } else { showAppSnackbar( title: "Error", - message: "Failed to reactivate document", + message: "Failed to restore document", type: SnackbarType.error, ); } @@ -226,8 +227,8 @@ class _UserDocumentsPageState extends State { permissionController .hasPermission(Permissions.modifyDocument)) const PopupMenuItem( - value: "activate", - child: Text("Activate"), + value: "restore", + child: Text("Restore"), ), ], ), @@ -307,11 +308,11 @@ class _UserDocumentsPageState extends State { filled: true, fillColor: Colors.white, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), borderSide: BorderSide(color: Colors.grey.shade300), ), ), @@ -331,7 +332,7 @@ class _UserDocumentsPageState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), child: IconButton( padding: EdgeInsets.zero, @@ -347,7 +348,7 @@ class _UserDocumentsPageState extends State { isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: - BorderRadius.vertical(top: Radius.circular(20)), + BorderRadius.vertical(top: Radius.circular(5)), ), builder: (_) => UserDocumentFilterBottomSheet( entityId: resolvedEntityId, @@ -382,14 +383,14 @@ class _UserDocumentsPageState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), child: PopupMenuButton( padding: EdgeInsets.zero, icon: const Icon(Icons.more_vert, size: 20, color: Colors.black87), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), ), itemBuilder: (context) => [ const PopupMenuItem( @@ -411,7 +412,7 @@ class _UserDocumentsPageState extends State { const Icon(Icons.visibility_off_outlined, size: 20, color: Colors.black87), const SizedBox(width: 10), - const Expanded(child: Text('Show Inactive')), + const Expanded(child: Text('Show Deleted Documents')), Switch.adaptive( value: docController.showInactive.value, activeColor: Colors.indigo, @@ -439,24 +440,24 @@ class _UserDocumentsPageState extends State { Widget _buildStatusHeader() { return Obx(() { final isInactive = docController.showInactive.value; + if (!isInactive) return const SizedBox.shrink(); // hide when active + return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: isInactive ? Colors.red.shade50 : Colors.green.shade50, + color: Colors.red.shade50, child: Row( children: [ Icon( - isInactive ? Icons.visibility_off : Icons.check_circle, - color: isInactive ? Colors.red : Colors.green, + Icons.visibility_off, + color: Colors.red, size: 18, ), const SizedBox(width: 8), Text( - isInactive - ? "Showing Inactive Documents" - : "Showing Active Documents", + "Showing Deleted Documents", style: TextStyle( - color: isInactive ? Colors.red : Colors.green, + color: Colors.red, fontWeight: FontWeight.w600, ), ), @@ -535,7 +536,21 @@ class _UserDocumentsPageState extends State { ), ] : [ - ...docs.map(_buildDocumentTile), + ...docs.asMap().entries.map((entry) { + final index = entry.key; + final doc = entry.value; + + final currentDate = DateFormat("dd MMM yyyy") + .format(doc.uploadedAt.toLocal()); + final prevDate = index > 0 + ? DateFormat("dd MMM yyyy").format( + docs[index - 1].uploadedAt.toLocal()) + : null; + + final showDateHeader = currentDate != prevDate; + + return _buildDocumentTile(doc, showDateHeader); + }), if (docController.isLoading.value) const Padding( padding: EdgeInsets.all(12), @@ -609,8 +624,11 @@ class _UserDocumentsPageState extends State { reset: true, ); } else { - Get.snackbar( - "Error", "Upload failed, please try again"); + showAppSnackbar( + title: "Error", + message: "Upload failed, please try again", + type: SnackbarType.error, + ); } }, ), diff --git a/lib/view/employees/employee_detail_screen.dart b/lib/view/employees/employee_detail_screen.dart index bcf0098..bcaecdc 100644 --- a/lib/view/employees/employee_detail_screen.dart +++ b/lib/view/employees/employee_detail_screen.dart @@ -153,7 +153,7 @@ class _EmployeeDetailPageState extends State { return Card( elevation: 3, shadowColor: Colors.black12, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), child: Padding( padding: const EdgeInsets.fromLTRB(12, 16, 12, 16), child: Column( diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 2e30d03..19c6e46 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -16,6 +16,8 @@ import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/controller/tenant/organization_selection_controller.dart'; +import 'package:marco/helpers/widgets/tenant/organization_selector.dart'; class EmployeesScreen extends StatefulWidget { const EmployeesScreen({super.key}); @@ -31,6 +33,8 @@ class _EmployeesScreenState extends State with UIMixin { Get.find(); final TextEditingController _searchController = TextEditingController(); final RxList _filteredEmployees = [].obs; + final OrganizationController _organizationController = + Get.put(OrganizationController()); @override void initState() { @@ -44,13 +48,19 @@ class _EmployeesScreenState extends State with UIMixin { Future _initEmployees() async { final projectId = Get.find().selectedProject?.id; + final orgId = _organizationController.selectedOrganization.value?.id; + + if (projectId != null) { + await _organizationController.fetchOrganizations(projectId); + } if (_employeeController.isAllEmployeeSelected.value) { _employeeController.selectedProjectId = null; - await _employeeController.fetchAllEmployees(); + await _employeeController.fetchAllEmployees(organizationId: orgId); } else if (projectId != null) { _employeeController.selectedProjectId = projectId; - await _employeeController.fetchEmployeesByProject(projectId); + await _employeeController.fetchEmployeesByProject(projectId, + organizationId: orgId); } else { _employeeController.clearEmployees(); } @@ -61,14 +71,16 @@ class _EmployeesScreenState extends State with UIMixin { Future _refreshEmployees() async { try { final projectId = Get.find().selectedProject?.id; + final orgId = _organizationController.selectedOrganization.value?.id; final allSelected = _employeeController.isAllEmployeeSelected.value; _employeeController.selectedProjectId = allSelected ? null : projectId; if (allSelected) { - await _employeeController.fetchAllEmployees(); + await _employeeController.fetchAllEmployees(organizationId: orgId); } else if (projectId != null) { - await _employeeController.fetchEmployeesByProject(projectId); + await _employeeController.fetchEmployeesByProject(projectId, + organizationId: orgId); } else { _employeeController.clearEmployees(); } @@ -267,12 +279,51 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildSearchAndActionRow() { return Padding( - padding: MySpacing.x(flexSpacing), - child: Row( + padding: MySpacing.x(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: _buildSearchField()), - const SizedBox(width: 8), - _buildPopupMenu(), + // Search Field Row + Row( + children: [ + Expanded(child: _buildSearchField()), + const SizedBox(width: 8), + _buildPopupMenu(), + ], + ), + + // Organization Selector Row + Row( + children: [ + Expanded( + child: OrganizationSelector( + controller: _organizationController, + height: 36, + onSelectionChanged: (org) async { + // Make sure the selectedOrganization is updated immediately + _organizationController.selectOrganization(org); + + final projectId = + Get.find().selectedProject?.id; + + if (_employeeController.isAllEmployeeSelected.value) { + await _employeeController.fetchAllEmployees( + organizationId: _organizationController + .selectedOrganization.value?.id); + } else if (projectId != null) { + await _employeeController.fetchEmployeesByProject( + projectId, + organizationId: _organizationController + .selectedOrganization.value?.id); + } + + _employeeController.update(['employee_screen_controller']); + }, + ), + ), + ], + ), + MySpacing.height(8), ], ), ); diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 0d6d100..ecb5b6e 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -21,6 +21,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:timeline_tile/timeline_tile.dart'; + class ExpenseDetailScreen extends StatefulWidget { final String expenseId; const ExpenseDetailScreen({super.key, required this.expenseId}); @@ -105,7 +106,7 @@ class _ExpenseDetailScreenState extends State { constraints: const BoxConstraints(maxWidth: 520), child: Card( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), + borderRadius: BorderRadius.circular(5)), elevation: 3, child: Padding( padding: const EdgeInsets.symmetric( @@ -123,14 +124,12 @@ class _ExpenseDetailScreenState extends State { const Divider(height: 30, thickness: 1.2), _InvoiceDocuments(documents: expense.documents), const Divider(height: 30, thickness: 1.2), - _InvoiceTotals( expense: expense, formattedAmount: formattedAmount, statusColor: statusColor, ), const Divider(height: 30, thickness: 1.2), - ], ), ), @@ -160,7 +159,7 @@ class _ExpenseDetailScreenState extends State { return const SizedBox.shrink(); } - return FloatingActionButton( + return FloatingActionButton.extended( onPressed: () async { final editData = { 'id': expense.id, @@ -197,8 +196,9 @@ class _ExpenseDetailScreenState extends State { await controller.fetchExpenseDetails(); }, backgroundColor: Colors.red, - tooltip: 'Edit Expense', - child: const Icon(Icons.edit), + icon: const Icon(Icons.edit), + label: MyText.bodyMedium( + "Edit Expense", fontWeight: 600, color: Colors.white), ); }), bottomNavigationBar: Obx(() { @@ -271,7 +271,7 @@ class _ExpenseDetailScreenState extends State { minimumSize: const Size(100, 40), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), backgroundColor: buttonColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), ), onPressed: () async { const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; @@ -280,7 +280,7 @@ class _ExpenseDetailScreenState extends State { context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + borderRadius: BorderRadius.vertical(top: Radius.circular(5))), builder: (context) => ReimbursementBottomSheet( expenseId: expense.id, statusId: next.id, @@ -470,7 +470,7 @@ class _InvoiceHeader extends StatelessWidget { Container( decoration: BoxDecoration( color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8)), + borderRadius: BorderRadius.circular(5)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row( children: [ @@ -604,7 +604,7 @@ class _InvoiceDocuments extends StatelessWidget { const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), color: Colors.grey.shade100, ), child: Row( @@ -679,7 +679,8 @@ class InvoiceLogs extends StatelessWidget { ), ), ), - beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), + beforeLineStyle: + LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12.0), child: Column( @@ -698,17 +699,20 @@ class InvoiceLogs extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Icon(Icons.access_time, size: 14, color: Colors.grey[600]), + Icon(Icons.access_time, + size: 14, color: Colors.grey[600]), const SizedBox(width: 4), - MyText.bodySmall(formattedDate, color: Colors.grey[700]), + MyText.bodySmall(formattedDate, + color: Colors.grey[700]), ], ), const SizedBox(height: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), decoration: BoxDecoration( color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( log.action, diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 97c9c2c..31b0306 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -408,4 +408,5 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ], ); } + } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index e7ce5e6..cf120f6 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -20,8 +20,9 @@ class ExpenseMainScreen extends StatefulWidget { State createState() => _ExpenseMainScreenState(); } -class _ExpenseMainScreenState extends State { - bool isHistoryView = false; +class _ExpenseMainScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; final searchController = TextEditingController(); final expenseController = Get.put(ExpenseController()); final projectController = Get.find(); @@ -30,9 +31,16 @@ class _ExpenseMainScreenState extends State { @override void initState() { super.initState(); + _tabController = TabController(length: 2, vsync: this); expenseController.fetchExpenses(); } + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + Future _refreshExpenses() async { await expenseController.fetchExpenses(); } @@ -49,7 +57,7 @@ class _ExpenseMainScreenState extends State { ); } - List _getFilteredExpenses() { + List _getFilteredExpenses({required bool isHistory}) { final query = searchController.text.trim().toLowerCase(); final now = DateTime.now(); @@ -61,7 +69,7 @@ class _ExpenseMainScreenState extends State { }).toList() ..sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); - return isHistoryView + return isHistory ? filtered .where((e) => e.transactionDate.isBefore(DateTime(now.year, now.month))) @@ -72,89 +80,121 @@ class _ExpenseMainScreenState extends State { e.transactionDate.year == now.year) .toList(); } - + @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - appBar: ExpenseAppBar(projectController: projectController), - body: SafeArea( - child: Column( - children: [ - SearchAndFilter( - controller: searchController, - onChanged: (_) => setState(() {}), - onFilterTap: _openFilterBottomSheet, - expenseController: expenseController, - ), - ToggleButtonsRow( - isHistoryView: isHistoryView, - onToggle: (v) => setState(() => isHistoryView = v), - ), - Expanded( - child: Obx(() { - // Loader while fetching first time - if (expenseController.isLoading.value && - expenseController.expenses.isEmpty) { - return SkeletonLoaders.expenseListSkeletonLoader(); - } - - final filteredList = _getFilteredExpenses(); - - return MyRefreshIndicator( - onRefresh: _refreshExpenses, - child: filteredList.isEmpty - ? ListView( - physics: - const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.5, - child: Center( - child: MyText.bodyMedium( - expenseController.errorMessage.isNotEmpty - ? expenseController.errorMessage.value - : "No expenses found", - color: - expenseController.errorMessage.isNotEmpty - ? Colors.red - : Colors.grey, - ), - ), - ), - ], - ) - : NotificationListener( - onNotification: (scrollInfo) { - if (scrollInfo.metrics.pixels == - scrollInfo.metrics.maxScrollExtent && - !expenseController.isLoading.value) { - expenseController.loadMoreExpenses(); - } - return false; - }, - child: ExpenseList( - expenseList: filteredList, - onViewDetail: () => - expenseController.fetchExpenses(), - ), - ), - ); - }), - ) - ], +Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: ExpenseAppBar(projectController: projectController), + body: Column( + children: [ + // ---------------- TabBar ---------------- + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.red, + tabs: const [ + Tab(text: "Current Month"), + Tab(text: "History"), + ], + ), ), - ), - // ✅ FAB only if user has expenseUpload permission - floatingActionButton: - permissionController.hasPermission(Permissions.expenseUpload) - ? FloatingActionButton( - backgroundColor: Colors.red, - onPressed: showAddExpenseBottomSheet, - child: const Icon(Icons.add, color: Colors.white), - ) - : null, - ); + // ---------------- Gray background for rest ---------------- + Expanded( + child: Container( + color: Colors.grey[100], // Light gray background + child: Column( + children: [ + // ---------------- Search ---------------- + Padding( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + child: SearchAndFilter( + controller: searchController, + onChanged: (_) => setState(() {}), + onFilterTap: _openFilterBottomSheet, + expenseController: expenseController, + ), + ), + + // ---------------- TabBarView ---------------- + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildExpenseList(isHistory: false), + _buildExpenseList(isHistory: true), + ], + ), + ), + ], + ), + ), + ), + ], + ), + + floatingActionButton: + permissionController.hasPermission(Permissions.expenseUpload) + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : null, + ); +} + + + Widget _buildExpenseList({required bool isHistory}) { + return Obx(() { + if (expenseController.isLoading.value && + expenseController.expenses.isEmpty) { + return SkeletonLoaders.expenseListSkeletonLoader(); + } + + final filteredList = _getFilteredExpenses(isHistory: isHistory); + + return MyRefreshIndicator( + onRefresh: _refreshExpenses, + child: filteredList.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: MyText.bodyMedium( + expenseController.errorMessage.isNotEmpty + ? expenseController.errorMessage.value + : "No expenses found", + color: expenseController.errorMessage.isNotEmpty + ? Colors.red + : Colors.grey, + ), + ), + ), + ], + ) + : NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent && + !expenseController.isLoading.value) { + expenseController.loadMoreExpenses(); + } + return false; + }, + child: ExpenseList( + expenseList: filteredList, + onViewDetail: () => expenseController.fetchExpenses(), + ), + ), + ); + }); } } + diff --git a/lib/view/layouts/layout.dart b/lib/view/layouts/layout.dart index 7c072cc..10405c3 100644 --- a/lib/view/layouts/layout.dart +++ b/lib/view/layouts/layout.dart @@ -122,7 +122,7 @@ class _LayoutState extends State { return Card( elevation: 4, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, @@ -133,12 +133,48 @@ class _LayoutState extends State { child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - Images.logoDark, - height: 50, - width: 50, - fit: BoxFit.contain, + borderRadius: BorderRadius.circular(5), + child: Stack( + clipBehavior: Clip.none, + children: [ + Image.asset( + Images.logoDark, + height: 50, + width: 50, + fit: BoxFit.contain, + ), + if (isBetaEnvironment) + Positioned( + bottom: 0, + left: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.deepPurple, + borderRadius: + BorderRadius.circular(6), // capsule shape + border: Border.all( + color: Colors.white, width: 1.2), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 2, + offset: Offset(0, 1), + ) + ], + ), + child: const Text( + 'B', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], ), ), const SizedBox(width: 12), @@ -218,21 +254,6 @@ class _LayoutState extends State { ], ), ), - if (isBetaEnvironment) - Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.deepPurple, - borderRadius: BorderRadius.circular(6), - ), - child: MyText.bodySmall( - 'BETA', - color: Colors.white, - fontWeight: 700, - ), - ), Stack( clipBehavior: Clip.none, alignment: Alignment.center, @@ -268,7 +289,7 @@ class _LayoutState extends State { left: 0, right: 0, child: Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(5), color: Colors.white, child: _buildProjectList(context, isMobile), ), @@ -285,7 +306,7 @@ class _LayoutState extends State { return Card( elevation: 4, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), margin: EdgeInsets.zero, child: Padding( @@ -297,7 +318,7 @@ class _LayoutState extends State { width: 50, decoration: BoxDecoration( color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), ), ), const SizedBox(width: 12), @@ -343,11 +364,11 @@ class _LayoutState extends State { right: 16, child: Material( elevation: 4, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), child: Container( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.all(10), child: _buildProjectList(context, isMobile), @@ -397,7 +418,7 @@ class _LayoutState extends State { ? Colors.blueAccent.withOpacity(0.1) : Colors.transparent, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(5), ), visualDensity: const VisualDensity(vertical: -4), ); diff --git a/lib/view/layouts/user_profile_right_bar.dart b/lib/view/layouts/user_profile_right_bar.dart index d3d86d8..a15d2d6 100644 --- a/lib/view/layouts/user_profile_right_bar.dart +++ b/lib/view/layouts/user_profile_right_bar.dart @@ -9,7 +9,10 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/model/employees/employee_info.dart'; import 'package:marco/controller/auth/mpin_controller.dart'; +import 'package:marco/controller/tenant/tenant_selection_controller.dart'; import 'package:marco/view/employees/employee_profile_screen.dart'; +import 'package:marco/helpers/services/tenant_service.dart'; +import 'package:marco/view/tenant/tenant_selection_screen.dart'; class UserProfileBar extends StatefulWidget { final bool isCondensed; @@ -24,13 +27,21 @@ class _UserProfileBarState extends State late EmployeeInfo employeeInfo; bool _isLoading = true; bool hasMpin = true; + late final TenantSelectionController _tenantController; @override void initState() { super.initState(); + _tenantController = Get.put(TenantSelectionController()); _initData(); } + @override + void dispose() { + Get.delete(); + super.dispose(); + } + Future _initData() async { employeeInfo = LocalStorage.getEmployeeInfo()!; hasMpin = await LocalStorage.getIsMpin(); @@ -80,6 +91,10 @@ class _UserProfileBarState extends State _isLoading ? const _LoadingSection() : _userProfileSection(isCondensed), + + // --- SWITCH TENANT ROW BELOW AVATAR --- + if (!_isLoading && !isCondensed) _switchTenantRow(), + MySpacing.height(12), Divider( indent: 18, @@ -106,6 +121,119 @@ class _UserProfileBarState extends State ); } + /// Row widget to switch tenant with popup menu (button only) + Widget _switchTenantRow() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Obx(() { + if (_tenantController.isLoading.value) return _loadingTenantContainer(); + + final tenants = _tenantController.tenants; + if (tenants.isEmpty) return _noTenantContainer(); + + final selectedTenant = TenantService.currentTenant; + + // Sort tenants: selected tenant first + final sortedTenants = List.of(tenants); + if (selectedTenant != null) { + sortedTenants.sort((a, b) { + if (a.id == selectedTenant.id) return -1; + if (b.id == selectedTenant.id) return 1; + return 0; + }); + } + + return PopupMenuButton( + onSelected: (tenantId) => + _tenantController.onTenantSelected(tenantId), + itemBuilder: (_) => sortedTenants.map((tenant) { + return PopupMenuItem( + value: tenant.id, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + width: 20, + height: 20, + color: Colors.grey.shade200, + child: TenantLogo(logoImage: tenant.logoImage), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + tenant.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: tenant.id == selectedTenant?.id + ? FontWeight.bold + : FontWeight.w600, + color: tenant.id == selectedTenant?.id + ? Colors.blueAccent + : Colors.black87, + ), + ), + ), + if (tenant.id == selectedTenant?.id) + const Icon(Icons.check_circle, + color: Colors.blueAccent, size: 18), + ], + ), + ); + }).toList(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(Icons.swap_horiz, color: Colors.blue.shade600), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + "Switch Organization", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.blue, fontWeight: FontWeight.bold), + ), + ), + ), + Icon(Icons.arrow_drop_down, color: Colors.blue.shade600), + ], + ), + ), + ); + }), + ); + } + + Widget _loadingTenantContainer() => Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200, width: 1), + ), + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + + Widget _noTenantContainer() => Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.shade200, width: 1), + ), + child: MyText.bodyMedium( + "No tenants available", + color: Colors.blueAccent, + fontWeight: 600, + ), + ); + Widget _userProfileSection(bool condensed) { final padding = MySpacing.fromLTRB( condensed ? 16 : 26, diff --git a/lib/view/taskPlanning/daily_progress.dart b/lib/view/taskPlanning/daily_progress.dart index 868ab7b..c102803 100644 --- a/lib/view/taskPlanning/daily_progress.dart +++ b/lib/view/taskPlanning/daily_progress.dart @@ -17,6 +17,8 @@ import 'package:marco/model/dailyTaskPlanning/task_action_buttons.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/controller/tenant/service_controller.dart'; +import 'package:marco/helpers/widgets/tenant/service_selector.dart'; class DailyProgressReportScreen extends StatefulWidget { const DailyProgressReportScreen({super.key}); @@ -41,28 +43,51 @@ class _DailyProgressReportScreenState extends State final PermissionController permissionController = Get.find(); final ProjectController projectController = Get.find(); + final ServiceController serviceController = Get.put(ServiceController()); + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); - + _scrollController.addListener(() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 100 && + dailyTaskController.hasMore && + !dailyTaskController.isLoadingMore.value) { + final projectId = dailyTaskController.selectedProjectId; + if (projectId != null && projectId.isNotEmpty) { + dailyTaskController.fetchTaskData( + projectId, + pageNumber: dailyTaskController.currentPage + 1, + pageSize: dailyTaskController.pageSize, + isLoadMore: true, + ); + } + } + }); final initialProjectId = projectController.selectedProjectId.value; if (initialProjectId.isNotEmpty) { dailyTaskController.selectedProjectId = initialProjectId; dailyTaskController.fetchTaskData(initialProjectId); + serviceController.fetchServices(initialProjectId); } - ever( - projectController.selectedProjectId, - (newProjectId) async { - if (newProjectId.isNotEmpty && - newProjectId != dailyTaskController.selectedProjectId) { - dailyTaskController.selectedProjectId = newProjectId; - await dailyTaskController.fetchTaskData(newProjectId); - dailyTaskController.update(['daily_progress_report_controller']); - } - }, - ); + // Update when project changes + ever(projectController.selectedProjectId, (newProjectId) async { + if (newProjectId.isNotEmpty && + newProjectId != dailyTaskController.selectedProjectId) { + dailyTaskController.selectedProjectId = newProjectId; + await dailyTaskController.fetchTaskData(newProjectId); + await serviceController.fetchServices(newProjectId); + dailyTaskController.update(['daily_progress_report_controller']); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); } @override @@ -131,8 +156,7 @@ class _DailyProgressReportScreenState extends State child: MyRefreshIndicator( onRefresh: _refreshData, child: CustomScrollView( - physics: - const AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: GetBuilder( @@ -143,6 +167,29 @@ class _DailyProgressReportScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(flexSpacing), + + // --- ADD SERVICE SELECTOR HERE --- + Padding( + padding: MySpacing.x(10), + child: ServiceSelector( + controller: serviceController, + height: 40, + onSelectionChanged: (service) async { + final projectId = + dailyTaskController.selectedProjectId; + if (projectId?.isNotEmpty ?? false) { + await dailyTaskController.fetchTaskData( + projectId!, + serviceIds: + service != null ? [service.id] : null, + pageNumber: 1, + pageSize: 20, + ); + } + }, + ), + ), + _buildActionBar(), Padding( padding: MySpacing.x(8), @@ -299,10 +346,12 @@ class _DailyProgressReportScreenState extends State final isLoading = dailyTaskController.isLoading.value; final groupedTasks = dailyTaskController.groupedDailyTasks; - if (isLoading) { + // Initial loading skeleton + if (isLoading && dailyTaskController.currentPage == 1) { return SkeletonLoaders.dailyProgressReportSkeletonLoader(); } + // No tasks if (groupedTasks.isEmpty) { return Center( child: MyText.bodySmall( @@ -315,23 +364,33 @@ class _DailyProgressReportScreenState extends State final sortedDates = groupedTasks.keys.toList() ..sort((a, b) => b.compareTo(a)); + // If only one date, make it expanded by default + if (sortedDates.length == 1 && + !dailyTaskController.expandedDates.contains(sortedDates[0])) { + dailyTaskController.expandedDates.add(sortedDates[0]); + } + return MyCard.bordered( borderRadiusAll: 10, border: Border.all(color: Colors.grey.withOpacity(0.2)), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), paddingAll: 8, - child: ListView.separated( + child: ListView.builder( + controller: _scrollController, shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: sortedDates.length, - separatorBuilder: (_, __) => Column( - children: [ - const SizedBox(height: 12), - Divider(color: Colors.grey.withOpacity(0.3), thickness: 1), - const SizedBox(height: 12), - ], - ), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: sortedDates.length + 1, // +1 for loading indicator itemBuilder: (context, dateIndex) { + // Bottom loading indicator + if (dateIndex == sortedDates.length) { + return Obx(() => dailyTaskController.isLoadingMore.value + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink()); + } + final dateKey = sortedDates[dateIndex]; final tasksForDate = groupedTasks[dateKey]!; final date = DateTime.tryParse(dateKey); @@ -367,7 +426,6 @@ class _DailyProgressReportScreenState extends State return Column( children: tasksForDate.asMap().entries.map((entry) { final task = entry.value; - final index = entry.key; final activityName = task.workItem?.activityMaster?.activityName ?? 'N/A'; @@ -385,134 +443,121 @@ class _DailyProgressReportScreenState extends State ? (completed / planned).clamp(0.0, 1.0) : 0.0; final parentTaskID = task.id; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MyContainer( + paddingAll: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium(activityName, fontWeight: 600), + const SizedBox(height: 2), + MyText.bodySmall(location, color: Colors.grey), + const SizedBox(height: 8), + GestureDetector( + onTap: () => _showTeamMembersBottomSheet( + task.teamMembers), + child: Row( + children: [ + const Icon(Icons.group, + size: 18, color: Colors.blueAccent), + const SizedBox(width: 6), + MyText.bodyMedium('Team', + color: Colors.blueAccent, + fontWeight: 600), + ], + ), + ), + const SizedBox(height: 8), + MyText.bodySmall( + "Completed: $completed / $planned", + fontWeight: 600, + color: Colors.black87, + ), + const SizedBox(height: 6), + Stack( children: [ - MyText.bodyMedium(activityName, - fontWeight: 600), - const SizedBox(height: 2), - MyText.bodySmall(location, - color: Colors.grey), - const SizedBox(height: 8), - GestureDetector( - onTap: () => _showTeamMembersBottomSheet( - task.teamMembers), - child: Row( - children: [ - const Icon(Icons.group, - size: 18, color: Colors.blueAccent), - const SizedBox(width: 6), - MyText.bodyMedium('Team', - color: Colors.blueAccent, - fontWeight: 600), - ], + Container( + height: 5, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), ), ), - const SizedBox(height: 8), - MyText.bodySmall( - "Completed: $completed / $planned", - fontWeight: 600, - color: Colors.black87, - ), - const SizedBox(height: 6), - Stack( - children: [ - Container( - height: 5, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: - BorderRadius.circular(6), - ), + FractionallySizedBox( + widthFactor: progress, + child: Container( + height: 5, + decoration: BoxDecoration( + color: progress >= 1.0 + ? Colors.green + : progress >= 0.5 + ? Colors.amber + : Colors.red, + borderRadius: BorderRadius.circular(6), ), - FractionallySizedBox( - widthFactor: progress, - child: Container( - height: 5, - decoration: BoxDecoration( - color: progress >= 1.0 - ? Colors.green - : progress >= 0.5 - ? Colors.amber - : Colors.red, - borderRadius: - BorderRadius.circular(6), - ), - ), - ), - ], - ), - const SizedBox(height: 4), - MyText.bodySmall( - "${(progress * 100).toStringAsFixed(1)}%", - fontWeight: 500, - color: progress >= 1.0 - ? Colors.green[700] - : progress >= 0.5 - ? Colors.amber[800] - : Colors.red[700], - ), - const SizedBox(height: 12), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if ((task.reportedDate == null || - task.reportedDate - .toString() - .isEmpty) && - permissionController.hasPermission( - Permissions - .assignReportTask)) ...[ - TaskActionButtons.reportButton( - context: context, - task: task, - completed: completed.toInt(), - refreshCallback: _refreshData, - ), - const SizedBox(width: 4), - ] else if (task.approvedBy == null && - permissionController.hasPermission( - Permissions.approveTask)) ...[ - TaskActionButtons.reportActionButton( - context: context, - task: task, - parentTaskID: parentTaskID, - workAreaId: workAreaId.toString(), - activityId: activityId.toString(), - completed: completed.toInt(), - refreshCallback: _refreshData, - ), - const SizedBox(width: 5), - ], - TaskActionButtons.commentButton( - context: context, - task: task, - parentTaskID: parentTaskID, - workAreaId: workAreaId.toString(), - activityId: activityId.toString(), - refreshCallback: _refreshData, - ), - ], ), ), ], ), - ), + const SizedBox(height: 4), + MyText.bodySmall( + "${(progress * 100).toStringAsFixed(1)}%", + fontWeight: 500, + color: progress >= 1.0 + ? Colors.green[700] + : progress >= 0.5 + ? Colors.amber[800] + : Colors.red[700], + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if ((task.reportedDate == null || + task.reportedDate + .toString() + .isEmpty) && + permissionController.hasPermission( + Permissions.assignReportTask)) ...[ + TaskActionButtons.reportButton( + context: context, + task: task, + completed: completed.toInt(), + refreshCallback: _refreshData, + ), + const SizedBox(width: 4), + ] else if (task.approvedBy == null && + permissionController.hasPermission( + Permissions.approveTask)) ...[ + TaskActionButtons.reportActionButton( + context: context, + task: task, + parentTaskID: parentTaskID, + workAreaId: workAreaId.toString(), + activityId: activityId.toString(), + completed: completed.toInt(), + refreshCallback: _refreshData, + ), + const SizedBox(width: 5), + ], + TaskActionButtons.commentButton( + context: context, + task: task, + parentTaskID: parentTaskID, + workAreaId: workAreaId.toString(), + activityId: activityId.toString(), + refreshCallback: _refreshData, + ), + ], + ), + ), + ], ), - if (index != tasksForDate.length - 1) - Divider( - color: Colors.grey.withOpacity(0.2), - thickness: 1, - height: 1), - ], + ), ); }).toList(), ); diff --git a/lib/view/taskPlanning/daily_task_planning.dart b/lib/view/taskPlanning/daily_task_planning.dart index b8bcb71..ebf4b3d 100644 --- a/lib/view/taskPlanning/daily_task_planning.dart +++ b/lib/view/taskPlanning/daily_task_planning.dart @@ -13,6 +13,8 @@ import 'package:marco/model/dailyTaskPlanning/assign_task_bottom_sheet .dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/controller/tenant/service_controller.dart'; +import 'package:marco/helpers/widgets/tenant/service_selector.dart'; class DailyTaskPlanningScreen extends StatefulWidget { DailyTaskPlanningScreen({super.key}); @@ -29,23 +31,25 @@ class _DailyTaskPlanningScreenState extends State final PermissionController permissionController = Get.put(PermissionController()); final ProjectController projectController = Get.find(); + final ServiceController serviceController = Get.put(ServiceController()); @override void initState() { super.initState(); - // Initial fetch if a project is already selected final projectId = projectController.selectedProjectId.value; if (projectId.isNotEmpty) { dailyTaskPlanningController.fetchTaskData(projectId); + serviceController.fetchServices(projectId); // <-- Fetch services here } - // Reactive fetch on project ID change ever( projectController.selectedProjectId, (newProjectId) { if (newProjectId.isNotEmpty) { dailyTaskPlanningController.fetchTaskData(newProjectId); + serviceController + .fetchServices(newProjectId); } }, ); @@ -143,6 +147,25 @@ class _DailyTaskPlanningScreenState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + MySpacing.height(flexSpacing), + Padding( + padding: MySpacing.x(10), + child: ServiceSelector( + controller: serviceController, + height: 40, + onSelectionChanged: (service) async { + final projectId = + projectController.selectedProjectId.value; + if (projectId.isNotEmpty) { + await dailyTaskPlanningController.fetchTaskData( + projectId, + // serviceId: service + // ?.id, + ); + } + }, + ), + ), MySpacing.height(flexSpacing), Padding( padding: MySpacing.x(8), diff --git a/lib/view/tenant/tenant_selection_screen.dart b/lib/view/tenant/tenant_selection_screen.dart new file mode 100644 index 0000000..d63a8b8 --- /dev/null +++ b/lib/view/tenant/tenant_selection_screen.dart @@ -0,0 +1,391 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/images.dart'; +import 'package:marco/controller/tenant/tenant_selection_controller.dart'; + +class TenantSelectionScreen extends StatefulWidget { + const TenantSelectionScreen({super.key}); + + @override + State createState() => _TenantSelectionScreenState(); +} + +class _TenantSelectionScreenState extends State + with UIMixin, SingleTickerProviderStateMixin { + late final TenantSelectionController _controller; + late final AnimationController _logoAnimController; + late final Animation _logoAnimation; + final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _controller = Get.put(TenantSelectionController()); + _logoAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _logoAnimation = CurvedAnimation( + parent: _logoAnimController, + curve: Curves.easeOutBack, + ); + _logoAnimController.forward(); + + // 🔥 Tell controller this is tenant selection screen + _controller.loadTenants(fromTenantSelectionScreen: true); + } + + @override + void dispose() { + _logoAnimController.dispose(); + Get.delete(); + super.dispose(); + } + + Future _onTenantSelected(String tenantId) async { + setState(() => _isLoading = true); + await _controller.onTenantSelected(tenantId); + setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + _RedWaveBackground(brandRed: contentTheme.brandRed), + SafeArea( + child: Center( + child: Column( + children: [ + const SizedBox(height: 24), + _AnimatedLogo(animation: _logoAnimation), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + children: [ + const SizedBox(height: 12), + const _WelcomeTexts(), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 12), + const _BetaBadge(), + ], + const SizedBox(height: 36), + // Tenant list directly reacts to controller + TenantCardList( + controller: _controller, + isLoading: _isLoading, + onTenantSelected: _onTenantSelected, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _AnimatedLogo extends StatelessWidget { + final Animation animation; + const _AnimatedLogo({required this.animation}); + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: animation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Image.asset(Images.logoDark), + ), + ); + } +} + +class _WelcomeTexts extends StatelessWidget { + const _WelcomeTexts(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + MyText( + "Welcome", + fontSize: 24, + fontWeight: 600, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Please select which dashboard you want to explore!.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _BetaBadge extends StatelessWidget { + const _BetaBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(5), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } +} + +class TenantCardList extends StatelessWidget { + final TenantSelectionController controller; + final bool isLoading; + final Function(String tenantId) onTenantSelected; + + const TenantCardList({ + required this.controller, + required this.isLoading, + required this.onTenantSelected, + }); + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoading.value || isLoading) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (controller.tenants.isEmpty) { + return Center( + child: MyText( + "No dashboards available for your account.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ); + } + if (controller.tenants.length == 1) { + return const SizedBox.shrink(); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...controller.tenants.map( + (tenant) => _TenantCard( + tenant: tenant, + onTap: () => onTenantSelected(tenant.id), + ), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back, + size: 20, color: Colors.redAccent), + label: MyText( + 'Back to Login', + color: Colors.red, + fontWeight: 600, + fontSize: 14, + ), + ), + ], + ), + ); + }); + } +} + +class _TenantCard extends StatelessWidget { + final dynamic tenant; + final VoidCallback onTap; + const _TenantCard({required this.tenant, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(5), + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + margin: const EdgeInsets.only(bottom: 20), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Container( + width: 60, + height: 60, + color: Colors.grey.shade200, + child: TenantLogo(logoImage: tenant.logoImage), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText( + tenant.name, + fontSize: 18, + fontWeight: 700, + color: Colors.black87, + ), + const SizedBox(height: 6), + MyText( + "Industry: ${tenant.industry?.name ?? "-"}", + fontSize: 13, + color: Colors.black54, + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + size: 24, + color: Colors.red, + ), + ], + ), + ), + ), + ); + } +} + +class TenantLogo extends StatelessWidget { + final String? logoImage; + const TenantLogo({required this.logoImage}); + + @override + Widget build(BuildContext context) { + if (logoImage == null || logoImage!.isEmpty) { + return Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ); + } + if (logoImage!.startsWith("data:image")) { + try { + final base64Str = logoImage!.split(',').last; + final bytes = base64Decode(base64Str); + return Image.memory(bytes, fit: BoxFit.cover); + } catch (_) { + return Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ); + } + } else { + return Image.network( + logoImage!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Center( + child: Icon(Icons.business, color: Colors.grey.shade600), + ), + ); + } + } +} + +class _RedWaveBackground extends StatelessWidget { + final Color brandRed; + const _RedWaveBackground({required this.brandRed}); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WavePainter(brandRed), + size: Size.infinite, + ); + } +} + +class _WavePainter extends CustomPainter { + final Color brandRed; + + _WavePainter(this.brandRed); + + @override + void paint(Canvas canvas, Size size) { + final paint1 = Paint() + ..shader = LinearGradient( + colors: [brandRed, const Color.fromARGB(255, 97, 22, 22)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + final path1 = Path() + ..moveTo(0, size.height * 0.2) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, + size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo( + size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + canvas.drawPath(path1, paint1); + + final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); + final path2 = Path() + ..moveTo(0, size.height * 0.25) + ..quadraticBezierTo( + size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) + ..lineTo(size.width, 0) + ..lineTo(0, 0) + ..close(); + canvas.drawPath(path2, paint2); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +}