Merge pull request 'Vaibhav_Enhancement-#1129' (#73) from Vaibhav_Enhancement-#1129 into main
Reviewed-on: #73
This commit is contained in:
commit
5086b3be98
@ -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<AttendanceLogModel> attendanceLogs = [];
|
||||
List<RegularizationLogModel> regularizationLogs = [];
|
||||
List<AttendanceLogViewModel> attendenceLogsView = [];
|
||||
// ------------------ Organizations ------------------
|
||||
List<Organization> 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<ProjectController>().selectedProject?.id;
|
||||
if (projectId != null) {
|
||||
fetchOrganizations(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeDefaults() {
|
||||
_setDefaultDateRange();
|
||||
fetchProjects();
|
||||
}
|
||||
|
||||
void _setDefaultDateRange() {
|
||||
@ -104,29 +113,15 @@ class AttendanceController extends GetxController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> 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<void> fetchEmployeesByProject(String? projectId) async {
|
||||
Future<void> 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<void> 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<bool> 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<void> 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 ------------------
|
||||
|
@ -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",
|
||||
|
@ -94,8 +94,9 @@ class AddContactController extends GetxController {
|
||||
required List<Map<String, String>> 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");
|
||||
|
@ -97,10 +97,13 @@ class DirectoryController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchCommentsForContact(String contactId) async {
|
||||
Future<void> 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] ??= <DirectoryComment>[].obs;
|
||||
@ -120,6 +124,80 @@ class DirectoryController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🗑️ Delete a comment (soft delete)
|
||||
Future<void> 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<void> 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<void> fetchBuckets() async {
|
||||
try {
|
||||
final response = await ApiService.getContactBucketList();
|
||||
|
@ -107,6 +107,49 @@ class NotesController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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");
|
||||
|
@ -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<String, dynamic>? editingEmployeeData; // For edit mode
|
||||
Map<String, dynamic>? editingEmployeeData;
|
||||
|
||||
List<PlatformFile> files = [];
|
||||
// State
|
||||
final MyFormValidator basicValidator = MyFormValidator();
|
||||
final List<PlatformFile> files = [];
|
||||
final List<String> categories = [];
|
||||
|
||||
Gender? selectedGender;
|
||||
List<Map<String, dynamic>> roles = [];
|
||||
String? selectedRoleId;
|
||||
String selectedCountryCode = "+91";
|
||||
String selectedCountryCode = '+91';
|
||||
bool showOnline = true;
|
||||
final List<String> 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<void> fetchRoles() async {
|
||||
logSafe("Fetching roles...");
|
||||
logSafe('Fetching roles...');
|
||||
try {
|
||||
final result = await ApiService.getRoles();
|
||||
if (result != null) {
|
||||
roles = List<Map<String, dynamic>>.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<Map<String, dynamic>?> createOrUpdateEmployee() async {
|
||||
// Create or update employee
|
||||
Future<Map<String, dynamic>?> 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<String>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
@ -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<ProjectController>().selectedProject?.id;
|
||||
if (projectId != null) {
|
||||
@ -66,21 +66,26 @@ class EmployeesScreenController extends GetxController {
|
||||
update(['employee_screen_controller']);
|
||||
}
|
||||
|
||||
Future<void> fetchAllEmployees() async {
|
||||
Future<void> 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<void> fetchEmployeesByProject(String? projectId) async {
|
||||
if (projectId == null || projectId.isEmpty) {
|
||||
logSafe("Project ID is required but was null or empty.",
|
||||
level: LogLevel.error);
|
||||
return;
|
||||
}
|
||||
Future<void> 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;
|
||||
|
@ -24,8 +24,12 @@ class DailyTaskController extends GetxController {
|
||||
}
|
||||
|
||||
RxBool isLoading = true.obs;
|
||||
RxBool isLoadingMore = false.obs;
|
||||
Map<String, List<TaskModel>> 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<void> fetchTaskData(String? projectId) async {
|
||||
if (projectId == null) {
|
||||
logSafe("fetchTaskData: Skipped, projectId is null",
|
||||
level: LogLevel.warning);
|
||||
return;
|
||||
Future<void> fetchTaskData(
|
||||
String projectId, {
|
||||
List<String>? 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<void> 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
|
||||
}
|
||||
}
|
||||
|
@ -131,7 +131,7 @@ class DailyTaskPlanningController extends GetxController {
|
||||
}
|
||||
|
||||
/// Fetch Infra details and then tasks per work area
|
||||
Future<void> fetchTaskData(String? projectId) async {
|
||||
Future<void> 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<dynamic>?;
|
||||
|
||||
@ -159,11 +160,12 @@ class DailyTaskPlanningController extends GetxController {
|
||||
return Floor(
|
||||
id: floorJson['id'],
|
||||
floorName: floorJson['floorName'],
|
||||
workAreas: (floorJson['workAreas'] as List<dynamic>).map((areaJson) {
|
||||
workAreas:
|
||||
(floorJson['workAreas'] as List<dynamic>).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<dynamic>? ?? [];
|
||||
|
||||
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();
|
||||
|
52
lib/controller/tenant/organization_selection_controller.dart
Normal file
52
lib/controller/tenant/organization_selection_controller.dart
Normal file
@ -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<Organization> organizations = [];
|
||||
|
||||
/// Currently selected organization (reactive)
|
||||
Rxn<Organization> selectedOrganization = Rxn<Organization>();
|
||||
|
||||
/// Loading state for fetching organizations
|
||||
final isLoadingOrganizations = false.obs;
|
||||
|
||||
/// Fetch organizations assigned to a given project
|
||||
Future<void> 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";
|
||||
}
|
43
lib/controller/tenant/service_controller.dart
Normal file
43
lib/controller/tenant/service_controller.dart
Normal file
@ -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<Service> services = [];
|
||||
Service? selectedService;
|
||||
final isLoadingServices = false.obs;
|
||||
|
||||
/// Fetch services assigned to a project
|
||||
Future<void> 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";
|
||||
}
|
106
lib/controller/tenant/tenant_selection_controller.dart
Normal file
106
lib/controller/tenant/tenant_selection_controller.dart
Normal file
@ -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 = <Tenant>[].obs;
|
||||
var isLoading = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
loadTenants();
|
||||
}
|
||||
|
||||
/// Load tenants from API
|
||||
Future<void> 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<void> 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
|
@ -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<http.Response?> _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<String, String>? additionalHeaders,
|
||||
Duration customTimeout = timeout,
|
||||
Duration customTimeout = extendedTimeout,
|
||||
bool hasRetried = false,
|
||||
}) async {
|
||||
String? token = await _getToken();
|
||||
@ -247,6 +249,106 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response?> _deleteRequest(
|
||||
String endpoint, {
|
||||
Map<String, String>? 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<OrganizationListResponse?> 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<ServiceListResponse?> 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<bool> postLogsApi(List<Map<String, dynamic>> 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<List<dynamic>?> 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<bool> 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<List<dynamic>?> 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<bool> updateContact(
|
||||
String contactId, Map<String, dynamic> payload) async {
|
||||
try {
|
||||
@ -1733,23 +1883,49 @@ class ApiService {
|
||||
_getRequest(ApiEndpoints.getGlobalProjects).then((res) =>
|
||||
res != null ? _parseResponse(res, label: 'Global Projects') : null);
|
||||
|
||||
static Future<List<dynamic>?> getEmployeesByProject(String projectId) async =>
|
||||
_getRequest(ApiEndpoints.getEmployeesByProject,
|
||||
queryParams: {"projectId": projectId})
|
||||
.then((res) =>
|
||||
res != null ? _parseResponse(res, label: 'Employees') : null);
|
||||
static Future<List<dynamic>?> 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<List<dynamic>?> 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<List<dynamic>?> 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<List<dynamic>?> getRegularizationLogs(String projectId) async =>
|
||||
_getRequest(ApiEndpoints.getRegularizationLogs,
|
||||
queryParams: {"projectId": projectId})
|
||||
.then((res) => res != null
|
||||
? _parseResponse(res, label: 'Regularization Logs')
|
||||
: null);
|
||||
|
||||
static Future<bool> uploadAttendanceImage(
|
||||
String id,
|
||||
String employeeId,
|
||||
@ -1859,11 +2028,15 @@ class ApiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> getAllEmployeesByProject(
|
||||
String projectId) async {
|
||||
static Future<List<dynamic>?> 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<List<dynamic>?> getAllEmployees() async =>
|
||||
_getRequest(ApiEndpoints.getAllEmployees).then((res) =>
|
||||
res != null ? _parseResponse(res, label: 'All Employees') : null);
|
||||
static Future<List<dynamic>?> 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<List<dynamic>?> 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<String>? 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<bool> reportTask({
|
||||
|
@ -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<void> initializeApp() async {
|
||||
await _handleAuthTokens();
|
||||
await _setupTheme();
|
||||
await _setupControllers();
|
||||
await _setupFirebaseMessaging();
|
||||
await _setupFirebaseMessaging();
|
||||
|
||||
_finalizeAppStyle();
|
||||
|
||||
@ -47,16 +46,9 @@ Future<void> initializeApp() async {
|
||||
Future<void> _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<void> _setupFirebase() async {
|
||||
await Firebase.initializeApp();
|
||||
logSafe("💡 Firebase initialized.");
|
||||
@ -126,7 +118,6 @@ Future<void> _setupFirebaseMessaging() async {
|
||||
logSafe("💡 Firebase Messaging initialized.");
|
||||
}
|
||||
|
||||
|
||||
void _finalizeAppStyle() {
|
||||
AppStyle.init();
|
||||
logSafe("💡 AppStyle initialized.");
|
||||
|
@ -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."};
|
||||
|
||||
|
@ -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<String, Map<String, dynamic>> _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<Map<String, dynamic>> 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<String, dynamic>;
|
||||
|
||||
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<void> _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<UserPermission> _parsePermissions(List<dynamic> 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<String, dynamic> data) {
|
||||
/// Robust model parsing for employee info
|
||||
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? 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<ProjectInfo> _parseProjectsInfo(List<dynamic> projects) {
|
||||
/// Robust model parsing for projects list
|
||||
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
|
||||
logSafe("Parsing projects info...");
|
||||
if (projects == null) return [];
|
||||
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
||||
}
|
||||
}
|
||||
|
@ -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<bool> setRecentTenantId(String tenantId) =>
|
||||
preferences.setString(_recentTenantKey, tenantId);
|
||||
|
||||
static String? getRecentTenantId() =>
|
||||
_initialized ? preferences.getString(_recentTenantKey) : null;
|
||||
|
||||
static Future<bool> removeRecentTenantId() =>
|
||||
preferences.remove(_recentTenantKey);
|
||||
|
||||
static SharedPreferences? _preferencesInstance;
|
||||
static bool _initialized = false;
|
||||
@ -76,7 +87,8 @@ class LocalStorage {
|
||||
static Future<bool> removeMenus() => preferences.remove(_menuStorageKey);
|
||||
|
||||
// ================== User Permissions ==================
|
||||
static Future<bool> setUserPermissions(List<UserPermission> permissions) async {
|
||||
static Future<bool> setUserPermissions(
|
||||
List<UserPermission> 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<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
|
||||
preferences.setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
|
||||
static Future<bool> 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);
|
||||
|
152
lib/helpers/services/tenant_service.dart
Normal file
152
lib/helpers/services/tenant_service.dart
Normal file
@ -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<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
|
||||
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
|
||||
}
|
||||
|
||||
/// Tenant API service
|
||||
class TenantService implements ITenantService {
|
||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||
static const Map<String, String> _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<Map<String, String>> _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<List<Map<String, dynamic>>?> 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<Map<String, dynamic>>.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<bool> 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>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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<String, Color> _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),
|
||||
);
|
||||
|
@ -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<DashboardController>();
|
||||
|
||||
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>[
|
||||
CircularChartAnnotation(
|
||||
widget: MyText(
|
||||
"${(percent * 100).toInt()}%",
|
||||
style: _infoNumberGreenTextStyle.copyWith(
|
||||
fontSize: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
series: <PieSeries<_ChartData, String>>[
|
||||
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<String, String> stats;
|
||||
final Map<String, Color>? 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>[
|
||||
CircularChartAnnotation(
|
||||
widget: Center(
|
||||
child: MyText(percentLabel, style: DashboardOverviewWidgets._percentStyle),
|
||||
),
|
||||
),
|
||||
],
|
||||
series: <DoughnutSeries<_ChartData, String>>[
|
||||
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);
|
||||
}
|
||||
|
@ -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<ChartTaskData> data;
|
||||
@ -50,13 +49,9 @@ class ProjectProgressChart extends StatelessWidget {
|
||||
];
|
||||
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
|
||||
|
||||
static final Map<String, Color> _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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
106
lib/helpers/widgets/tenant/organization_selector.dart
Normal file
106
lib/helpers/widgets/tenant/organization_selector.dart
Normal file
@ -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<void> 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<String> items,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
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<String>(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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
114
lib/helpers/widgets/tenant/service_selector.dart
Normal file
114
lib/helpers/widgets/tenant/service_selector.dart
Normal file
@ -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<void> 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<String> items,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
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<String>(
|
||||
enabled: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
"No services found",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return items
|
||||
.map((e) => PopupMenuItem<String>(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
|
||||
? <String>[]
|
||||
: <String>[
|
||||
"All Services",
|
||||
...controller.services.map((e) => e.name).toList(),
|
||||
];
|
||||
|
||||
final currentValue =
|
||||
controller.services.isEmpty ? "" : controller.currentSelection;
|
||||
|
||||
return _popupSelector(
|
||||
currentValue: currentValue,
|
||||
items: serviceNames,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -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<String, dynamic> 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<String, dynamic> json) {
|
||||
return Employee(
|
||||
id: json['id'],
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
photo: json['photo']?.toString(),
|
||||
jobRoleId: json['jobRoleId'] ?? '',
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
|
||||
|
@ -193,7 +193,7 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
|
||||
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);
|
||||
|
@ -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<String> items,
|
||||
required ValueChanged<String> onSelected,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) => items
|
||||
.map((e) => PopupMenuItem<String>(
|
||||
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<Widget> buildMainFilters() {
|
||||
final hasRegularizationPermission = widget.permissionController
|
||||
.hasPermission(Permissions.regularizeAttendance);
|
||||
@ -61,7 +128,7 @@ class _AttendanceFilterBottomSheetState
|
||||
|
||||
final List<Widget> 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,
|
||||
|
@ -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<AttendanceLogViewButton> createState() =>
|
||||
_AttendanceLogViewButtonState();
|
||||
}
|
||||
|
||||
class _AttendanceLogViewButtonState extends State<AttendanceLogViewButton> {
|
||||
Future<void> _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<int, bool> 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);
|
||||
}
|
||||
|
106
lib/model/attendance/organization_per_project_list_model.dart
Normal file
106
lib/model/attendance/organization_per_project_list_model.dart
Normal file
@ -0,0 +1,106 @@
|
||||
class OrganizationListResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<Organization> 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<String, dynamic> json) {
|
||||
return OrganizationListResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.map((e) => Organization.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: json['timestamp'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -249,7 +249,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionHeader("Add Comment", Icons.comment_outlined),
|
||||
_buildSectionHeader("Add Note", Icons.comment_outlined),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
validator:
|
||||
|
@ -65,7 +65,7 @@ class _AddCommentBottomSheetState extends State<AddCommentBottomSheet> {
|
||||
),
|
||||
),
|
||||
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,
|
||||
|
@ -24,6 +24,7 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
||||
|
||||
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<AddContactBottomSheet> {
|
||||
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<AddContactBottomSheet> {
|
||||
void dispose() {
|
||||
nameCtrl.dispose();
|
||||
orgCtrl.dispose();
|
||||
designationCtrl.dispose();
|
||||
addrCtrl.dispose();
|
||||
descCtrl.dispose();
|
||||
tagCtrl.dispose();
|
||||
@ -118,6 +121,20 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
||||
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<AddContactBottomSheet> {
|
||||
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<AddContactBottomSheet> {
|
||||
phones: phones,
|
||||
address: addrCtrl.text.trim(),
|
||||
description: descCtrl.text.trim(),
|
||||
designation: designationCtrl.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -412,12 +430,12 @@ class _AddContactBottomSheetState extends State<AddContactBottomSheet> {
|
||||
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<AddContactBottomSheet> {
|
||||
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()),
|
||||
|
@ -2,6 +2,7 @@ class ContactModel {
|
||||
final String id;
|
||||
final List<String>? projectIds;
|
||||
final String name;
|
||||
final String? designation;
|
||||
final List<ContactPhone> contactPhones;
|
||||
final List<ContactEmail> 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;
|
||||
|
@ -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<String, dynamic> json) {
|
||||
|
@ -393,6 +393,7 @@ class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -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: [
|
||||
|
@ -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<String, dynamic>? employeeData;
|
||||
AddEmployeeBottomSheet({this.employeeData});
|
||||
const AddEmployeeBottomSheet({super.key, this.employeeData});
|
||||
|
||||
@override
|
||||
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
|
||||
@ -22,28 +24,88 @@ class AddEmployeeBottomSheet extends StatefulWidget {
|
||||
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
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<AddEmployeeController>(
|
||||
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<AddEmployeeBottomSheet>
|
||||
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<AddEmployeeBottomSheet>
|
||||
),
|
||||
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<AddEmployeeBottomSheet>
|
||||
_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<Color>((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<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _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<void> _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<EmployeesScreenController>();
|
||||
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<AddEmployeeBottomSheet>
|
||||
],
|
||||
);
|
||||
|
||||
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<AddEmployeeBottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _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<void> _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<EmployeesScreenController>();
|
||||
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<String>(
|
||||
context: context,
|
||||
position: _popupMenuPosition(context),
|
||||
items: orgs
|
||||
.map(
|
||||
(org) => PopupMenuItem<String>(
|
||||
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<Gender>(
|
||||
context: context,
|
||||
position: _popupMenuPosition(context),
|
||||
items: Gender.values.map((gender) {
|
||||
return PopupMenuItem<Gender>(
|
||||
value: gender,
|
||||
child: Text(gender.name.capitalizeFirst!),
|
||||
);
|
||||
}).toList(),
|
||||
items: Gender.values
|
||||
.map(
|
||||
(gender) => PopupMenuItem<Gender>(
|
||||
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<AddEmployeeBottomSheet>
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: _popupMenuPosition(context),
|
||||
items: _controller.roles.map((role) {
|
||||
return PopupMenuItem<String>(
|
||||
value: role['id'],
|
||||
child: Text(role['name']),
|
||||
);
|
||||
}).toList(),
|
||||
items: _controller.roles
|
||||
.map(
|
||||
(role) => PopupMenuItem<String>(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'projectAddress': projectAddress,
|
||||
'contactPerson': contactPerson,
|
||||
'startDate': startDate.toIso8601String(),
|
||||
'endDate': endDate.toIso8601String(),
|
||||
'teamSize': teamSize,
|
||||
'projectStatusId': projectStatusId,
|
||||
'tenantId': tenantId,
|
||||
};
|
||||
}
|
||||
}
|
@ -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<String, dynamic> 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<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
|
109
lib/model/tenant/tenant_list_model.dart
Normal file
109
lib/model/tenant/tenant_list_model.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
|
||||
return Industry(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> json) {
|
||||
return TenantStatus(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
}
|
78
lib/model/tenant/tenant_services_model.dart
Normal file
78
lib/model/tenant/tenant_services_model.dart
Normal file
@ -0,0 +1,78 @@
|
||||
class ServiceListResponse {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<Service> 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<String, dynamic> json) {
|
||||
return ServiceListResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>?)
|
||||
?.map((e) => Service.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
errors: json['errors'],
|
||||
statusCode: json['statusCode'] ?? 0,
|
||||
timestamp: json['timestamp'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<String, dynamic> json) {
|
||||
return Service(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
isSystem: json['isSystem'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'isSystem': isSystem,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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<AttendanceLogsTab> {
|
||||
} 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;
|
||||
}
|
||||
|
||||
|
@ -47,6 +47,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
|
||||
Future<void> _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<AttendanceScreen> with UIMixin {
|
||||
|
||||
Future<void> _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<AttendanceScreen> 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<AttendanceScreen> with UIMixin {
|
||||
|
||||
if (selectedView != null && selectedView != selectedTab) {
|
||||
setState(() => selectedTab = selectedView);
|
||||
attendanceController.selectedTab = selectedView;
|
||||
if (selectedProjectId.isNotEmpty) {
|
||||
await attendanceController
|
||||
.fetchProjectData(selectedProjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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<DashboardScreen> 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<DashboardScreen> 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<DashboardScreen> 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<DashboardScreen> 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<DashboardScreen> 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<DashboardScreen> 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,
|
||||
|
@ -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<ContactDetailScreen> {
|
||||
projectController = Get.find<ProjectController>();
|
||||
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<ContactDetailScreen> {
|
||||
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<ContactDetailScreen> {
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: "Details"),
|
||||
Tab(text: "Comments"),
|
||||
Tab(text: "Notes"),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -340,51 +344,48 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
|
||||
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<ContactDetailScreen> {
|
||||
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<ContactDetailScreen> {
|
||||
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<ContactDetailScreen> {
|
||||
)
|
||||
: 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<ContactDetailScreen> {
|
||||
"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,
|
||||
),
|
||||
},
|
||||
|
@ -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<DirectoryMainScreen> createState() => _DirectoryMainScreenState();
|
||||
}
|
||||
|
||||
class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
||||
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(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -144,15 +144,38 @@ class _DirectoryViewState extends State<DirectoryView> {
|
||||
);
|
||||
}
|
||||
|
||||
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<DirectoryView> {
|
||||
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<DirectoryView> {
|
||||
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<DirectoryView> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: PopupMenuButton<int>(
|
||||
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<PopupMenuEntry<int>> menuItems = [];
|
||||
@ -375,7 +398,7 @@ class _DirectoryViewState extends State<DirectoryView> {
|
||||
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<DirectoryView> {
|
||||
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(
|
||||
|
@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
@ -109,7 +109,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
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<DocumentDetailsPage> {
|
||||
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<DocumentDetailsPage> {
|
||||
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<DocumentDetailsPage> {
|
||||
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<DocumentDetailsPage> {
|
||||
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,
|
||||
|
@ -67,7 +67,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
|
||||
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<UserDocumentsPage> {
|
||||
permissionController
|
||||
.hasPermission(Permissions.modifyDocument))
|
||||
const PopupMenuItem(
|
||||
value: "activate",
|
||||
child: Text("Activate"),
|
||||
value: "restore",
|
||||
child: Text("Restore"),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -307,11 +308,11 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: PopupMenuButton<int>(
|
||||
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<int>(
|
||||
@ -411,7 +412,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
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<UserDocumentsPage> {
|
||||
),
|
||||
]
|
||||
: [
|
||||
...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<UserDocumentsPage> {
|
||||
reset: true,
|
||||
);
|
||||
} else {
|
||||
Get.snackbar(
|
||||
"Error", "Upload failed, please try again");
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Upload failed, please try again",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -153,7 +153,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
|
||||
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(
|
||||
|
@ -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<EmployeesScreen> with UIMixin {
|
||||
Get.find<PermissionController>();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
||||
final OrganizationController _organizationController =
|
||||
Get.put(OrganizationController());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -44,13 +48,19 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
||||
|
||||
Future<void> _initEmployees() async {
|
||||
final projectId = Get.find<ProjectController>().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<EmployeesScreen> with UIMixin {
|
||||
Future<void> _refreshEmployees() async {
|
||||
try {
|
||||
final projectId = Get.find<ProjectController>().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<EmployeesScreen> 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<ProjectController>().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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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<ExpenseDetailScreen> {
|
||||
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<ExpenseDetailScreen> {
|
||||
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<ExpenseDetailScreen> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () async {
|
||||
final editData = {
|
||||
'id': expense.id,
|
||||
@ -197,8 +196,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
||||
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<ExpenseDetailScreen> {
|
||||
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<ExpenseDetailScreen> {
|
||||
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,
|
||||
|
@ -408,4 +408,5 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -20,8 +20,9 @@ class ExpenseMainScreen extends StatefulWidget {
|
||||
State<ExpenseMainScreen> createState() => _ExpenseMainScreenState();
|
||||
}
|
||||
|
||||
class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
bool isHistoryView = false;
|
||||
class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final searchController = TextEditingController();
|
||||
final expenseController = Get.put(ExpenseController());
|
||||
final projectController = Get.find<ProjectController>();
|
||||
@ -30,9 +31,16 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
expenseController.fetchExpenses();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshExpenses() async {
|
||||
await expenseController.fetchExpenses();
|
||||
}
|
||||
@ -49,7 +57,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<ExpenseModel> _getFilteredExpenses() {
|
||||
List<ExpenseModel> _getFilteredExpenses({required bool isHistory}) {
|
||||
final query = searchController.text.trim().toLowerCase();
|
||||
final now = DateTime.now();
|
||||
|
||||
@ -61,7 +69,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
}).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<ExpenseMainScreen> {
|
||||
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<ScrollNotification>(
|
||||
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<ScrollNotification>(
|
||||
onNotification: (scrollInfo) {
|
||||
if (scrollInfo.metrics.pixels ==
|
||||
scrollInfo.metrics.maxScrollExtent &&
|
||||
!expenseController.isLoading.value) {
|
||||
expenseController.loadMoreExpenses();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ExpenseList(
|
||||
expenseList: filteredList,
|
||||
onViewDetail: () => expenseController.fetchExpenses(),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,7 +122,7 @@ class _LayoutState extends State<Layout> {
|
||||
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<Layout> {
|
||||
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<Layout> {
|
||||
],
|
||||
),
|
||||
),
|
||||
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<Layout> {
|
||||
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<Layout> {
|
||||
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<Layout> {
|
||||
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<Layout> {
|
||||
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<Layout> {
|
||||
? Colors.blueAccent.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
visualDensity: const VisualDensity(vertical: -4),
|
||||
);
|
||||
|
@ -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<UserProfileBar>
|
||||
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<TenantSelectionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initData() async {
|
||||
employeeInfo = LocalStorage.getEmployeeInfo()!;
|
||||
hasMpin = await LocalStorage.getIsMpin();
|
||||
@ -80,6 +91,10 @@ class _UserProfileBarState extends State<UserProfileBar>
|
||||
_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<UserProfileBar>
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<String>(
|
||||
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,
|
||||
|
@ -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<DailyProgressReportScreen>
|
||||
final PermissionController permissionController =
|
||||
Get.find<PermissionController>();
|
||||
final ProjectController projectController = Get.find<ProjectController>();
|
||||
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<String>(
|
||||
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<String>(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<DailyProgressReportScreen>
|
||||
child: MyRefreshIndicator(
|
||||
onRefresh: _refreshData,
|
||||
child: CustomScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: GetBuilder<DailyTaskController>(
|
||||
@ -143,6 +167,29 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
|
||||
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<DailyProgressReportScreen>
|
||||
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<DailyProgressReportScreen>
|
||||
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<DailyProgressReportScreen>
|
||||
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<DailyProgressReportScreen>
|
||||
? (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(),
|
||||
);
|
||||
|
@ -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<DailyTaskPlanningScreen>
|
||||
final PermissionController permissionController =
|
||||
Get.put(PermissionController());
|
||||
final ProjectController projectController = Get.find<ProjectController>();
|
||||
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<String>(
|
||||
projectController.selectedProjectId,
|
||||
(newProjectId) {
|
||||
if (newProjectId.isNotEmpty) {
|
||||
dailyTaskPlanningController.fetchTaskData(newProjectId);
|
||||
serviceController
|
||||
.fetchServices(newProjectId);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -143,6 +147,25 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
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),
|
||||
|
391
lib/view/tenant/tenant_selection_screen.dart
Normal file
391
lib/view/tenant/tenant_selection_screen.dart
Normal file
@ -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<TenantSelectionScreen> createState() => _TenantSelectionScreenState();
|
||||
}
|
||||
|
||||
class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
||||
with UIMixin, SingleTickerProviderStateMixin {
|
||||
late final TenantSelectionController _controller;
|
||||
late final AnimationController _logoAnimController;
|
||||
late final Animation<double> _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<TenantSelectionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<double> 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user