Merge pull request 'Vaibhav_Task-#1177' (#70) from Vaibhav_Task-#1177 into main

Reviewed-on: #70
This commit is contained in:
vaibhav.surve 2025-09-19 04:39:58 +00:00
commit 85d776b60b
14 changed files with 665 additions and 271 deletions

View File

@ -70,9 +70,9 @@ class DashboardController extends GetxController {
ever(projectSelectedRange, (_) => fetchProjectProgress());
}
/// =========================
/// Helper Methods
/// =========================
// =========================
// Helper Methods
// =========================
int _getDaysFromRange(String range) {
switch (range) {
case '7D':
@ -114,21 +114,28 @@ class DashboardController extends GetxController {
logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug);
}
/// =========================
/// Manual refresh
/// =========================
// =========================
// Manual Refresh Methods
// =========================
Future<void> refreshDashboard() async {
logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug);
await fetchAllDashboardData();
}
/// =========================
/// Fetch all dashboard data
/// =========================
Future<void> refreshAttendance() async => fetchRoleWiseAttendance();
Future<void> refreshTasks() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId);
}
Future<void> refreshProjects() async => fetchProjectProgress();
// =========================
// Fetch All Dashboard Data
// =========================
Future<void> fetchAllDashboardData() async {
final String projectId = projectController.selectedProjectId.value;
// Skip fetching if no project is selected
if (projectId.isEmpty) {
logSafe('No project selected. Skipping dashboard API calls.',
level: LogLevel.warning);
@ -143,17 +150,15 @@ class DashboardController extends GetxController {
]);
}
/// =========================
/// API Calls
/// =========================
// =========================
// API Calls
// =========================
Future<void> fetchRoleWiseAttendance() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isAttendanceLoading.value = true;
final List<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(
projectId, getAttendanceDays());
@ -179,16 +184,12 @@ class DashboardController extends GetxController {
Future<void> fetchProjectProgress() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isProjectLoading.value = true;
final response = await ApiService.getProjectProgress(
projectId: projectId,
days: getProjectDays(),
);
projectId: projectId, days: getProjectDays());
if (response != null && response.success) {
projectChartData.value =
@ -208,11 +209,10 @@ class DashboardController extends GetxController {
}
Future<void> fetchDashboardTasks({required String projectId}) async {
if (projectId.isEmpty) return; // Skip if empty
if (projectId.isEmpty) return;
try {
isTasksLoading.value = true;
final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response != null && response.success) {
@ -235,11 +235,10 @@ class DashboardController extends GetxController {
}
Future<void> fetchDashboardTeams({required String projectId}) async {
if (projectId.isEmpty) return; // Skip if empty
if (projectId.isEmpty) return;
try {
isTeamsLoading.value = true;
final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response != null && response.success) {

View File

@ -10,12 +10,11 @@ class DocumentController extends GetxController {
var documents = <DocumentItem>[].obs;
var filters = Rxn<DocumentFiltersData>();
// Selected filters
var selectedFilter = "".obs;
var selectedUploadedBy = "".obs;
var selectedCategory = "".obs;
var selectedType = "".obs;
var selectedTag = "".obs;
// Selected filters (multi-select support)
var selectedUploadedBy = <String>[].obs;
var selectedCategory = <String>[].obs;
var selectedType = <String>[].obs;
var selectedTag = <String>[].obs;
// Pagination state
var pageNumber = 1.obs;
@ -31,6 +30,11 @@ class DocumentController extends GetxController {
// NEW: search
var searchQuery = ''.obs;
var searchController = TextEditingController();
// New filter fields
var isUploadedAt = true.obs;
var isVerified = RxnBool();
var startDate = Rxn<String>();
var endDate = Rxn<String>();
// ------------------ API Calls -----------------------
@ -157,17 +161,21 @@ class DocumentController extends GetxController {
/// Clear selected filters
void clearFilters() {
selectedUploadedBy.value = "";
selectedCategory.value = "";
selectedType.value = "";
selectedTag.value = "";
selectedUploadedBy.clear();
selectedCategory.clear();
selectedType.clear();
selectedTag.clear();
isUploadedAt.value = true;
isVerified.value = null;
startDate.value = null;
endDate.value = null;
}
/// Check if any filters are active (for red dot indicator)
bool hasActiveFilters() {
return selectedUploadedBy.value.isNotEmpty ||
selectedCategory.value.isNotEmpty ||
selectedType.value.isNotEmpty ||
selectedTag.value.isNotEmpty;
return selectedUploadedBy.isNotEmpty ||
selectedCategory.isNotEmpty ||
selectedType.isNotEmpty ||
selectedTag.isNotEmpty;
}
}

View File

@ -7,6 +7,7 @@ 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,
@ -17,6 +18,8 @@ enum Gender {
}
class AddEmployeeController extends MyController {
Map<String, dynamic>? editingEmployeeData; // For edit mode
List<PlatformFile> files = [];
final MyFormValidator basicValidator = MyFormValidator();
Gender? selectedGender;
@ -33,12 +36,10 @@ class AddEmployeeController extends MyController {
logSafe("Initializing AddEmployeeController...");
_initializeFields();
fetchRoles();
}
void setJoiningDate(DateTime date) {
joiningDate = date;
logSafe("Joining date selected: $date");
update();
if (editingEmployeeData != null) {
prefillFields();
}
}
void _initializeFields() {
@ -63,6 +64,37 @@ class AddEmployeeController extends MyController {
logSafe("Fields initialized for first_name, phone_number, last_name.");
}
/// Prefill fields in edit mode
// In AddEmployeeController
void prefillFields() {
logSafe("Prefilling data for editing...");
basicValidator.getController('first_name')?.text =
editingEmployeeData?['first_name'] ?? '';
basicValidator.getController('last_name')?.text =
editingEmployeeData?['last_name'] ?? '';
basicValidator.getController('phone_number')?.text =
editingEmployeeData?['phone_number'] ?? '';
selectedGender = editingEmployeeData?['gender'] != null
? Gender.values
.firstWhereOrNull((g) => g.name == editingEmployeeData!['gender'])
: null;
selectedRoleId = editingEmployeeData?['job_role_id'];
if (editingEmployeeData?['joining_date'] != null) {
joiningDate = DateTime.tryParse(editingEmployeeData!['joining_date']);
}
update();
}
void setJoiningDate(DateTime date) {
joiningDate = date;
logSafe("Joining date selected: $date");
update();
}
void onGenderSelected(Gender? gender) {
selectedGender = gender;
logSafe("Gender selected: ${gender?.name}");
@ -92,10 +124,13 @@ class AddEmployeeController extends MyController {
update();
}
Future<Map<String, dynamic>?> createEmployees() async {
logSafe("Starting employee creation...");
/// Create or update employee
Future<Map<String, dynamic>?> createOrUpdateEmployee() async {
logSafe(editingEmployeeData != null
? "Starting employee update..."
: "Starting employee creation...");
if (selectedGender == null || selectedRoleId == null) {
logSafe("Missing gender or role.", level: LogLevel.warning);
showAppSnackbar(
title: "Missing Fields",
message: "Please select both Gender and Role.",
@ -111,6 +146,7 @@ class AddEmployeeController extends MyController {
try {
final response = await ApiService.createEmployee(
id: editingEmployeeData?['id'], // Pass id if editing
firstName: firstName!,
lastName: lastName!,
phoneNumber: phoneNumber!,
@ -122,25 +158,25 @@ class AddEmployeeController extends MyController {
logSafe("Response: $response");
if (response != null && response['success'] == true) {
logSafe("Employee created successfully.");
showAppSnackbar(
title: "Success",
message: "Employee created successfully!",
message: editingEmployeeData != null
? "Employee updated successfully!"
: "Employee created successfully!",
type: SnackbarType.success,
);
return response;
} else {
logSafe("Failed to create employee (response false)",
level: LogLevel.error);
logSafe("Failed operation", level: LogLevel.error);
}
} catch (e, st) {
logSafe("Error creating employee",
logSafe("Error creating/updating employee",
level: LogLevel.error, error: e, stackTrace: st);
}
showAppSnackbar(
title: "Error",
message: "Failed to create employee.",
message: "Failed to save employee.",
type: SnackbarType.error,
);
return null;

View File

@ -1880,6 +1880,7 @@ class ApiService {
_getRequest(ApiEndpoints.getRoles).then(
(res) => res != null ? _parseResponse(res, label: 'Roles') : null);
static Future<Map<String, dynamic>?> createEmployee({
String? id, // Optional, for editing
required String firstName,
required String lastName,
required String phoneNumber,
@ -1888,12 +1889,13 @@ class ApiService {
required String joiningDate,
}) async {
final body = {
if (id != null) "id": id, // Include id only if editing
"firstName": firstName,
"lastName": lastName,
"phoneNumber": phoneNumber,
"gender": gender,
"jobRoleId": jobRoleId,
"joiningDate": joiningDate
"joiningDate": joiningDate,
};
final response = await _postRequest(
@ -1907,7 +1909,7 @@ class ApiService {
final json = jsonDecode(response.body);
return {
"success": response.statusCode == 200 && json['success'] == true,
"data": json
"data": json,
};
}

View File

@ -9,8 +9,9 @@ import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/directory/directory_controller.dart';
import 'package:marco/controller/directory/notes_controller.dart';
import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/utils/permission_constants.dart';
/// Handles incoming FCM notification actions and updates UI/controllers.
class NotificationActionHandler {
@ -46,6 +47,10 @@ class NotificationActionHandler {
break;
case 'attendance_updated':
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data); // refresh dashboard attendance
break;
case 'dashboard_update':
_handleDashboardUpdate(data); // full dashboard refresh
break;
default:
_logger.w('⚠️ Unknown notification type: $type');
@ -60,16 +65,19 @@ class NotificationActionHandler {
case 'Attendance':
if (_isAttendanceAction(action)) {
_handleAttendanceUpdated(data);
_handleDashboardUpdate(data);
}
break;
/// 🔹 Tasks
case 'Report_Task':
_handleTaskUpdated(data, isComment: false);
_handleDashboardUpdate(data);
break;
case 'Task_Comment':
_handleTaskUpdated(data, isComment: true);
_handleDashboardUpdate(data);
break;
case 'Task_Modified':
@ -77,11 +85,13 @@ class NotificationActionHandler {
case 'Floor_Modified':
case 'Building_Modified':
_handleTaskPlanningUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Expenses
case 'Expenses_Modified':
_handleExpenseUpdated(data);
_handleDashboardUpdate(data);
break;
/// 🔹 Documents
@ -198,12 +208,12 @@ class NotificationActionHandler {
}
/// ---------------------- DOCUMENT HANDLER ----------------------
/// ---------------------- DOCUMENT HANDLER ----------------------
static void _handleDocumentModified(Map<String, dynamic> data) {
late String entityTypeId;
late String entityId;
String entityTypeId;
String entityId;
String? documentId = data['DocumentId'];
// Determine entity type and ID
if (data['Keyword'] == 'Employee_Document_Modified') {
entityTypeId = Permissions.employeeEntity;
entityId = data['EmployeeId'] ?? '';
@ -220,7 +230,11 @@ static void _handleDocumentModified(Map<String, dynamic> data) {
return;
}
// 🔹 Refresh document list
_logger.i(
"🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId");
// Refresh Document List
if (Get.isRegistered<DocumentController>()) {
_safeControllerUpdate<DocumentController>(
onFound: (controller) async {
await controller.fetchDocuments(
@ -229,57 +243,68 @@ static void _handleDocumentModified(Map<String, dynamic> data) {
reset: true,
);
},
notFoundMessage: '⚠️ DocumentController not found, cannot refresh list.',
notFoundMessage:
'⚠️ DocumentController not found, cannot refresh list.',
successMessage: '✅ DocumentController refreshed from notification.',
);
} else {
_logger.w('⚠️ DocumentController not registered, skipping list refresh.');
}
// 🔹 Refresh document details (if opened)
if (documentId != null) {
// Refresh Document Details (if open)
if (documentId != null && Get.isRegistered<DocumentDetailsController>()) {
_safeControllerUpdate<DocumentDetailsController>(
onFound: (controller) async {
if (controller.documentDetails.value?.data?.id == documentId) {
// Refresh details regardless of current document
await controller.fetchDocumentDetails(documentId);
_logger.i("✅ DocumentDetailsController refreshed for Document $documentId");
}
_logger.i(
"✅ DocumentDetailsController refreshed for Document $documentId");
},
notFoundMessage: ' DocumentDetailsController not active, skipping.',
notFoundMessage:
' DocumentDetailsController not active, skipping details refresh.',
successMessage: '✅ DocumentDetailsController checked for refresh.',
);
} else if (documentId != null) {
_logger.w(
'⚠️ DocumentDetailsController not registered, cannot refresh document details.');
}
}
/// ---------------------- DIRECTORY HANDLERS ----------------------
/// ---------------------- DIRECTORY HANDLERS ----------------------
static void _handleContactModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchContacts(),
notFoundMessage: '⚠️ DirectoryController not found, cannot refresh.',
successMessage: '✅ Directory contacts refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
final contactId = data['contactId'];
final contactId = data['ContactId'];
// Always refresh the contact list
_safeControllerUpdate<DirectoryController>(
onFound: (controller) {
controller.fetchContacts();
// If a specific contact is provided, refresh its notes as well
if (contactId != null) {
controller.fetchCommentsForContact(contactId);
}
},
notFoundMessage:
'⚠️ DirectoryController not found, cannot refresh notes.',
successMessage: '✅ Directory comments refreshed from notification.',
'⚠️ DirectoryController not found, cannot refresh contacts.',
successMessage:
'✅ Directory contacts (and notes if applicable) refreshed from notification.',
);
// Refresh notes globally as well
_safeControllerUpdate<NotesController>(
onFound: (controller) => controller.fetchNotes(),
notFoundMessage: '⚠️ NotesController not found, cannot refresh.',
notFoundMessage: '⚠️ NotesController not found, cannot refresh notes.',
successMessage: '✅ Notes refreshed from notification.',
);
}
static void _handleContactNoteModified(Map<String, dynamic> data) {
final contactId = data['ContactId'];
// Refresh both contacts and notes when a note is modified
_handleContactModified(data);
}
static void _handleBucketModified(Map<String, dynamic> data) {
_safeControllerUpdate<DirectoryController>(
onFound: (controller) => controller.fetchBuckets(),
@ -296,6 +321,33 @@ static void _handleDocumentModified(Map<String, dynamic> data) {
);
}
/// ---------------------- DASHBOARD HANDLER ----------------------
static void _handleDashboardUpdate(Map<String, dynamic> data) {
_safeControllerUpdate<DashboardController>(
onFound: (controller) async {
final type = data['type'] ?? '';
switch (type) {
case 'attendance_updated':
await controller.fetchRoleWiseAttendance();
break;
case 'task_updated':
await controller.fetchDashboardTasks(
projectId:
controller.projectController.selectedProjectId.value);
break;
case 'project_progress_update':
await controller.fetchProjectProgress();
break;
case 'full_dashboard_refresh':
default:
await controller.refreshDashboard();
}
},
notFoundMessage: '⚠️ DashboardController not found, cannot refresh.',
successMessage: '✅ DashboardController refreshed from notification.',
);
}
/// ---------------------- UTILITY ----------------------
static void _safeControllerUpdate<T>({

View File

@ -154,8 +154,10 @@ class TileContainer extends StatelessWidget {
const TileContainer({required this.child, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) =>
Container(padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child);
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(14),
decoration: _tileDecoration,
child: child);
}
/// ==========================
@ -187,9 +189,8 @@ class AttachmentsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() {
final activeExisting = existingAttachments
.where((doc) => doc['isActive'] != false)
.toList();
final activeExisting =
existingAttachments.where((doc) => doc['isActive'] != false).toList();
final imageFiles = attachments.where(_isImageFile).toList();
final imageExisting = activeExisting
@ -336,8 +337,8 @@ class AttachmentsSection extends StatelessWidget {
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
width: 50,
height: 50,
decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade400),
),
@ -359,7 +360,8 @@ class _AttachmentTile extends StatelessWidget {
Widget build(BuildContext context) {
final fileName = file.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase();
final isImage = AttachmentsSection.allowedImageExtensions.contains(extension);
final isImage =
AttachmentsSection.allowedImageExtensions.contains(extension);
final (icon, color) = _fileIcon(extension);

View File

@ -129,6 +129,7 @@ class WorkItem {
final WorkCategoryMaster? workCategoryMaster;
final double? plannedWork;
final double? completedWork;
final double? todaysAssigned;
final DateTime? taskDate;
final String? tenantId;
final Tenant? tenant;
@ -143,6 +144,7 @@ class WorkItem {
this.workCategoryMaster,
this.plannedWork,
this.completedWork,
this.todaysAssigned,
this.taskDate,
this.tenantId,
this.tenant,
@ -171,6 +173,9 @@ class WorkItem {
completedWork: json['completedWork'] != null
? (json['completedWork'] as num).toDouble()
: null,
todaysAssigned: json['todaysAssigned'] != null
? (json['todaysAssigned'] as num).toDouble()
: null, // added parsing
taskDate:
json['taskDate'] != null ? DateTime.tryParse(json['taskDate']) : null,
tenantId: json['tenantId'] as String?,

View File

@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/document/user_document_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.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/model/document/document_filter_model.dart';
import 'dart:convert';
class UserDocumentFilterBottomSheet extends StatelessWidget {
final String entityId;
@ -36,15 +38,21 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
onCancel: () => Get.back(),
onSubmit: () {
final combinedFilter = {
'uploadedBy': docController.selectedUploadedBy.value,
'category': docController.selectedCategory.value,
'type': docController.selectedType.value,
'tag': docController.selectedTag.value,
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds': docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
'isUploadedAt': docController.isUploadedAt.value,
'startDate': docController.startDate.value,
'endDate': docController.endDate.value,
if (docController.isVerified.value != null)
'isVerified': docController.isVerified.value,
};
docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: entityId,
filter: combinedFilter.toString(),
filter: jsonEncode(combinedFilter),
reset: true,
);
Get.back();
@ -67,32 +75,209 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
),
),
),
// --- Date Filter (Uploaded On / Updated On) ---
_buildField(
"Choose Date",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Segmented Buttons
Obx(() {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () =>
docController.isUploadedAt.value = true,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10),
decoration: BoxDecoration(
color: docController.isUploadedAt.value
? Colors.indigo.shade400
: Colors.transparent,
borderRadius:
const BorderRadius.horizontal(
left: Radius.circular(24),
),
),
child: Center(
child: MyText(
"Uploaded On",
style: MyTextStyle.bodyMedium(
color:
docController.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => docController
.isUploadedAt.value = false,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10),
decoration: BoxDecoration(
color: !docController.isUploadedAt.value
? Colors.indigo.shade400
: Colors.transparent,
borderRadius:
const BorderRadius.horizontal(
right: Radius.circular(24),
),
),
child: Center(
child: MyText(
"Updated On",
style: MyTextStyle.bodyMedium(
color: !docController
.isUploadedAt.value
? Colors.white
: Colors.black87,
fontWeight: 600,
),
),
),
),
),
),
],
),
);
}),
MySpacing.height(12),
// Date Range
Row(
children: [
Expanded(
child: Obx(() {
return _dateButton(
label: docController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
DateTime.parse(
docController.startDate.value!),
'dd MMM yyyy',
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
docController.startDate.value =
picked.toIso8601String();
}
},
);
}),
),
MySpacing.width(12),
Expanded(
child: Obx(() {
return _dateButton(
label: docController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
DateTime.parse(
docController.endDate.value!),
'dd MMM yyyy',
),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null) {
docController.endDate.value =
picked.toIso8601String();
}
},
);
}),
),
],
),
],
),
),
MySpacing.height(8),
_buildDynamicField(
_multiSelectField(
label: "Uploaded By",
items: filterData.uploadedBy,
fallback: "Select Uploaded By",
selectedValue: docController.selectedUploadedBy,
selectedValues: docController.selectedUploadedBy,
),
_buildDynamicField(
_multiSelectField(
label: "Category",
items: filterData.documentCategory,
fallback: "Select Category",
selectedValue: docController.selectedCategory,
selectedValues: docController.selectedCategory,
),
_buildDynamicField(
_multiSelectField(
label: "Type",
items: filterData.documentType,
fallback: "Select Type",
selectedValue: docController.selectedType,
selectedValues: docController.selectedType,
),
_buildDynamicField(
_multiSelectField(
label: "Tag",
items: filterData.documentTag,
fallback: "Select Tag",
selectedValue: docController.selectedTag,
selectedValues: docController.selectedTag,
),
].where((w) => w != null).cast<Widget>().toList(),
// --- Verified Toggle ---
_buildField(
"Only Verified",
Obx(() {
return Container(
padding: MySpacing.xy(12, 6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText(
"Verified Documents Only",
style: MyTextStyle.bodyMedium(),
),
Switch(
value: docController.isVerified.value ?? false,
onChanged: (val) {
docController.isVerified.value =
val ? true : null;
},
activeColor: Colors.indigo,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
),
);
}),
),
],
)
: Center(
child: Padding(
@ -110,42 +295,95 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
);
}
Widget? _buildDynamicField({
Widget _multiSelectField({
required String label,
required List<FilterItem> items,
required String fallback,
required RxString selectedValue,
required RxList<String> selectedValues,
}) {
if (items.isEmpty) return null;
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
_popupSelector(items, fallback, selectedValue: selectedValue),
MySpacing.height(16),
],
);
}
Obx(() {
final selectedNames = items
.where((f) => selectedValues.contains(f.id))
.map((f) => f.name)
.join(", ");
final displayText =
selectedNames.isNotEmpty ? selectedNames : fallback;
Widget _popupSelector(
List<FilterItem> items,
String fallback, {
required RxString selectedValue,
}) {
return Obx(() {
final currentValue = _getCurrentName(selectedValue.value, items, fallback);
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (val) => selectedValue.value = val,
itemBuilder: (context) => items
.map(
(f) => PopupMenuItem<String>(
value: f.id,
child: MyText(f.name),
return Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final RenderBox button =
context.findRenderObject() as RenderBox;
final RenderBox overlay = Overlay.of(context)
.context
.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero);
await showMenu(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0,
),
)
.toList(),
items: items.map(
(f) {
return PopupMenuItem<String>(
enabled: false, // prevent auto-close
child: StatefulBuilder(
builder: (context, setState) {
final isChecked = selectedValues.contains(f.id);
return CheckboxListTile(
dense: true,
value: isChecked,
contentPadding: EdgeInsets.zero,
controlAffinity:
ListTileControlAffinity.leading,
title: MyText(f.name),
// --- Styles ---
checkColor: Colors.white, // tick color
side: const BorderSide(
color: Colors.black,
width: 1.5), // border when unchecked
fillColor:
MaterialStateProperty.resolveWith<Color>(
(states) {
if (states
.contains(MaterialState.selected)) {
return Colors.indigo; // checked Indigo
}
return Colors.white; // unchecked White
},
),
onChanged: (val) {
if (val == true) {
selectedValues.add(f.id);
} else {
selectedValues.remove(f.id);
}
setState(() {}); // refresh UI
},
);
},
),
);
},
).toList(),
);
},
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
@ -158,7 +396,7 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
children: [
Expanded(
child: MyText(
currentValue,
displayText,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
@ -168,12 +406,50 @@ class UserDocumentFilterBottomSheet extends StatelessWidget {
),
),
);
});
},
);
}),
MySpacing.height(16),
],
);
}
String _getCurrentName(String selectedId, List<FilterItem> list, String fallback) {
if (selectedId.isEmpty) return fallback;
final match = list.firstWhereOrNull((f) => f.id == selectedId);
return match?.name ?? fallback;
Widget _buildField(String label, Widget child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium(label),
MySpacing.height(8),
child,
MySpacing.height(8),
],
);
}
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: MySpacing.xy(16, 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: MyText(
label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@ -11,15 +11,31 @@ 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});
@override
State<AddEmployeeBottomSheet> createState() => _AddEmployeeBottomSheetState();
}
class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
with UIMixin {
final AddEmployeeController _controller = Get.put(AddEmployeeController());
late final AddEmployeeController _controller;
@override
void initState() {
super.initState();
_controller = Get.put(
AddEmployeeController(),
tag: UniqueKey().toString(),
);
if (widget.employeeData != null) {
_controller.editingEmployeeData = widget.employeeData;
_controller.prefillFields();
}
}
@override
Widget build(BuildContext context) {
@ -27,7 +43,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
init: _controller,
builder: (_) {
return BaseBottomSheet(
title: "Add Employee",
title: widget.employeeData != null ? "Edit Employee" : "Add Employee",
onCancel: () => Navigator.pop(context),
onSubmit: _handleSubmit,
child: Form(
@ -98,7 +114,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Common label with red star ---
Widget _requiredLabel(String text) {
return Row(
children: [
@ -109,7 +124,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Date Picker field ---
Widget _buildDatePickerField({
required String label,
required String value,
@ -146,7 +160,7 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
Future<void> _pickJoiningDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
initialDate: _controller.joiningDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
@ -157,54 +171,25 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
}
}
// --- Submit logic ---
Future<void> _handleSubmit() async {
// Run form validation first
final isValid =
_controller.basicValidator.formKey.currentState?.validate() ?? false;
if (!isValid) {
if (!isValid ||
_controller.joiningDate == null ||
_controller.selectedGender == null ||
_controller.selectedRoleId == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please fill all required fields before submitting.",
message: "Please complete all required fields.",
type: SnackbarType.warning,
);
return;
}
// Additional check for dropdowns & joining date
if (_controller.joiningDate == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Joining Date.",
type: SnackbarType.warning,
);
return;
}
if (_controller.selectedGender == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Gender.",
type: SnackbarType.warning,
);
return;
}
if (_controller.selectedRoleId == null) {
showAppSnackbar(
title: "Missing Fields",
message: "Please select Role.",
type: SnackbarType.warning,
);
return;
}
// All validations passed Call API
final result = await _controller.createEmployees();
final result = await _controller.createOrUpdateEmployee();
if (result != null && result['success'] == true) {
final employeeData = result['data'];
final employeeController = Get.find<EmployeesScreenController>();
final projectId = employeeController.selectedProjectId;
@ -216,20 +201,10 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
employeeController.update(['employee_screen_controller']);
// Reset form
_controller.basicValidator.getController("first_name")?.clear();
_controller.basicValidator.getController("last_name")?.clear();
_controller.basicValidator.getController("phone_number")?.clear();
_controller.selectedGender = null;
_controller.selectedRoleId = null;
_controller.joiningDate = null;
_controller.update();
Navigator.pop(context, employeeData);
Navigator.pop(context, result['data']);
}
}
// --- Section label widget ---
Widget _sectionLabel(String title) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -239,7 +214,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
],
);
// --- Input field with icon ---
Widget _inputWithIcon({
required String label,
required String hint,
@ -268,7 +242,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Phone input ---
Widget _buildPhoneInput(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -322,7 +295,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Dropdown (Gender/Role) ---
Widget _buildDropdownField({
required String label,
required String value,
@ -356,7 +328,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Common input decoration ---
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
@ -379,7 +350,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
);
}
// --- Gender popup ---
void _showGenderPopup(BuildContext context) async {
final selected = await showMenu<Gender>(
context: context,
@ -398,7 +368,6 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
}
}
// --- Role popup ---
void _showRolePopup(BuildContext context) async {
final selected = await showMenu<String>(
context: context,

View File

@ -63,6 +63,7 @@ String _convertDeltaToHtml(dynamic delta) {
class ContactDetailScreen extends StatefulWidget {
final ContactModel contact;
const ContactDetailScreen({super.key, required this.contact});
@override
State<ContactDetailScreen> createState() => _ContactDetailScreenState();
}
@ -70,16 +71,25 @@ class ContactDetailScreen extends StatefulWidget {
class _ContactDetailScreenState extends State<ContactDetailScreen> {
late final DirectoryController directoryController;
late final ProjectController projectController;
late ContactModel contact;
late Rx<ContactModel> contactRx;
@override
void initState() {
super.initState();
directoryController = Get.find<DirectoryController>();
projectController = Get.find<ProjectController>();
contact = widget.contact;
contactRx = widget.contact.obs;
WidgetsBinding.instance.addPostFrameCallback((_) {
directoryController.fetchCommentsForContact(contact.id);
directoryController.fetchCommentsForContact(contactRx.value.id);
});
// Listen to controller's allContacts and update contact if changed
ever(directoryController.allContacts, (_) {
final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contactRx.value.id);
if (updated != null) contactRx.value = updated;
});
}
@ -94,12 +104,12 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSubHeader(),
Obx(() => _buildSubHeader(contactRx.value)),
const Divider(height: 1, thickness: 0.5, color: Colors.grey),
Expanded(
child: TabBarView(children: [
_buildDetailsTab(),
_buildCommentsTab(context),
Obx(() => _buildDetailsTab(contactRx.value)),
_buildCommentsTab(),
]),
),
],
@ -135,9 +145,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
MyText.titleLarge('Contact Profile',
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (p) => ProjectLabel(p.selectedProject?.name),
),
GetBuilder<ProjectController>(builder: (p) {
return ProjectLabel(p.selectedProject?.name);
}),
],
),
),
@ -147,7 +157,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
);
}
Widget _buildSubHeader() {
Widget _buildSubHeader(ContactModel contact) {
final firstName = contact.name.split(" ").first;
final lastName =
contact.name.split(" ").length > 1 ? contact.name.split(" ").last : "";
@ -196,7 +206,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
);
}
Widget _buildDetailsTab() {
Widget _buildDetailsTab(ContactModel contact) {
final tags = contact.tags.map((e) => e.name).join(", ");
final bucketNames = contact.bucketIds
.map((id) => directoryController.contactBuckets
@ -249,7 +259,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(12),
// BASIC INFO CARD
_infoCard("Basic Info", [
multiRows(
items:
@ -273,20 +282,17 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
_iconInfoRow(Icons.location_on, "Address", contact.address),
]),
// ORGANIZATION CARD
_infoCard("Organization", [
_iconInfoRow(
Icons.business, "Organization", contact.organization),
_iconInfoRow(Icons.category, "Category", category),
]),
// META INFO CARD
_infoCard("Meta Info", [
_iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"),
_iconInfoRow(Icons.folder_shared, "Contact Buckets",
bucketNames.isNotEmpty ? bucketNames : "-"),
_iconInfoRow(Icons.work_outline, "Projects", projectNames),
]),
// DESCRIPTION CARD
_infoCard("Description", [
MySpacing.height(6),
Align(
@ -318,7 +324,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
final updated = directoryController.allContacts
.firstWhereOrNull((c) => c.id == contact.id);
if (updated != null) {
setState(() => contact = updated);
contactRx.value = updated;
}
}
},
@ -331,9 +337,9 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
);
}
Widget _buildCommentsTab(BuildContext context) {
Widget _buildCommentsTab() {
return Obx(() {
final contactId = contact.id;
final contactId = contactRx.value.id;
if (!directoryController.contactCommentsMap.containsKey(contactId)) {
return const Center(child: CircularProgressIndicator());
}
@ -355,7 +361,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
height: Get.height * 0.6,
child: Center(
child: MyText.bodyLarge(
"No comments yet.",
@ -375,7 +381,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
itemBuilder: (_, index) => _buildCommentItem(
comments[index],
editingId,
contact.id,
contactId,
),
),
),
@ -438,7 +444,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -474,7 +479,6 @@ class _ContactDetailScreenState extends State<ContactDetailScreen> {
),
],
),
// Comment Content
if (isEditing && quillController != null)
CommentEditorCard(
controller: quillController,

View File

@ -591,7 +591,7 @@ class _DirectoryViewState extends State<DirectoryView> {
FontAwesomeIcons
.whatsapp,
color: Colors.green,
size: 16,
size: 25,
),
),
],

View File

@ -25,7 +25,8 @@ class DocumentDetailsPage extends StatefulWidget {
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
Get.find<DocumentDetailsController>();
final PermissionController permissionController =
Get.find<PermissionController>();
@override

View File

@ -17,6 +17,8 @@ import 'package:marco/helpers/widgets/custom_app_bar.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/controller/document/document_details_controller.dart';
import 'dart:convert';
class UserDocumentsPage extends StatefulWidget {
final String? entityId;
@ -36,7 +38,8 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
final DocumentController docController = Get.put(DocumentController());
final PermissionController permissionController =
Get.find<PermissionController>();
final DocumentDetailsController controller =
Get.put(DocumentDetailsController());
String get entityTypeId => widget.isEmployee
? Permissions.employeeEntity
: Permissions.projectEntity;
@ -504,10 +507,18 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
Expanded(
child: MyRefreshIndicator(
onRefresh: () async {
final combinedFilter = {
'uploadedByIds': docController.selectedUploadedBy.toList(),
'documentCategoryIds':
docController.selectedCategory.toList(),
'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(),
};
await docController.fetchDocuments(
entityTypeId: entityTypeId,
entityId: resolvedEntityId,
filter: docController.selectedFilter.value,
filter: jsonEncode(combinedFilter),
reset: true,
);
},
@ -576,8 +587,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => DocumentUploadBottomSheet(
isEmployee:
widget.isEmployee, // 👈 Pass the employee flag here
isEmployee: widget.isEmployee,
onSubmit: (data) async {
final success = await uploadController.uploadDocument(
name: data["name"],

View File

@ -11,6 +11,7 @@ import 'package:marco/helpers/utils/launcher_utils.dart';
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/model/employees/add_employee_bottom_sheet.dart';
class EmployeeDetailPage extends StatefulWidget {
final String employeeId;
@ -92,8 +93,9 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
fontWeight: FontWeight.normal,
color: (isEmail || isPhone) ? Colors.indigo : Colors.black54,
fontSize: 14,
decoration:
(isEmail || isPhone) ? TextDecoration.underline : TextDecoration.none,
decoration: (isEmail || isPhone)
? TextDecoration.underline
: TextDecoration.none,
),
),
);
@ -231,7 +233,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
lastName: employee.lastName,
size: 45,
),
MySpacing.width(16),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -248,6 +250,34 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> {
],
),
),
IconButton(
icon: const Icon(Icons.edit,
size: 24, color: Colors.red),
onPressed: () async {
final result =
await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddEmployeeBottomSheet(
employeeData: {
'id': employee.id,
'first_name': employee.firstName,
'last_name': employee.lastName,
'phone_number': employee.phoneNumber,
'gender': employee.gender.toLowerCase(),
'job_role_id': employee.jobRoleId,
'joining_date':
employee.joiningDate?.toIso8601String(),
},
),
);
if (result != null) {
controller.fetchEmployeeDetails(widget.employeeId);
}
},
),
],
),
MySpacing.height(14),