Refactor Attendance Logs and Regularization Requests Tabs

- Changed AttendanceLogsTab from StatelessWidget to StatefulWidget to manage state for showing pending actions.
- Added a status header in AttendanceLogsTab to indicate when only pending actions are displayed.
- Updated filtering logic in AttendanceLogsTab to use filteredLogs based on the pending actions toggle.
- Refactored AttendanceScreen to include a search bar for filtering attendance logs by name.
- Introduced a new filter icon in AttendanceScreen for accessing the filter options.
- Updated RegularizationRequestsTab to use filteredRegularizationLogs for displaying requests.
- Modified TodaysAttendanceTab to utilize filteredEmployees for showing today's attendance.
- Cleaned up code formatting and improved readability across various files.
This commit is contained in:
Vaibhav Surve 2025-09-16 18:06:19 +05:30
parent 2517f2360e
commit 8fb725a5cf
11 changed files with 1196 additions and 960 deletions

View File

@ -39,6 +39,7 @@ class AttendanceController extends GetxController {
final isLoadingRegularizationLogs = true.obs;
final isLoadingLogView = true.obs;
final uploadingStates = <String, RxBool>{}.obs;
var showPendingOnly = false.obs;
@override
void onInit() {
@ -73,6 +74,36 @@ class AttendanceController extends GetxController {
"Attendance data refreshed from notification for project $projectId");
}
// 🔍 Search query
final searchQuery = ''.obs;
// Computed filtered employees
List<EmployeeModel> get filteredEmployees {
if (searchQuery.value.isEmpty) return employees;
return employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered logs
List<AttendanceLogModel> get filteredLogs {
if (searchQuery.value.isEmpty) return attendanceLogs;
return attendanceLogs
.where((log) =>
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
// Computed filtered regularization logs
List<RegularizationLogModel> get filteredRegularizationLogs {
if (searchQuery.value.isEmpty) return regularizationLogs;
return regularizationLogs
.where((log) =>
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList();
}
Future<void> fetchProjects() async {
isLoadingProjects.value = true;

View File

@ -7,6 +7,8 @@ import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
@ -15,21 +17,30 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.dart';
class AddExpenseController extends GetxController {
// --- Text Controllers ---
final amountController = TextEditingController();
final descriptionController = TextEditingController();
final supplierController = TextEditingController();
final transactionIdController = TextEditingController();
final gstController = TextEditingController();
final locationController = TextEditingController();
final transactionDateController = TextEditingController();
final noOfPersonsController = TextEditingController();
final controllers = <TextEditingController>[
TextEditingController(), // amount
TextEditingController(), // description
TextEditingController(), // supplier
TextEditingController(), // transactionId
TextEditingController(), // gst
TextEditingController(), // location
TextEditingController(), // transactionDate
TextEditingController(), // noOfPersons
TextEditingController(), // employeeSearch
];
final employeeSearchController = TextEditingController();
TextEditingController get amountController => controllers[0];
TextEditingController get descriptionController => controllers[1];
TextEditingController get supplierController => controllers[2];
TextEditingController get transactionIdController => controllers[3];
TextEditingController get gstController => controllers[4];
TextEditingController get locationController => controllers[5];
TextEditingController get transactionDateController => controllers[6];
TextEditingController get noOfPersonsController => controllers[7];
TextEditingController get employeeSearchController => controllers[8];
// --- Reactive State ---
final isLoading = false.obs;
@ -59,29 +70,19 @@ class AddExpenseController extends GetxController {
final expenseController = Get.find<ExpenseController>();
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
fetchMasterData();
fetchGlobalProjects();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
loadMasterData();
employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text),
);
}
@override
void onClose() {
for (var c in [
amountController,
descriptionController,
supplierController,
transactionIdController,
gstController,
locationController,
transactionDateController,
noOfPersonsController,
employeeSearchController,
]) {
for (var c in controllers) {
c.dispose();
}
super.onClose();
@ -92,11 +93,19 @@ class AddExpenseController extends GetxController {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
final data = await ApiService.searchEmployeesBasic(
searchString: query.trim(),
);
if (data is List) {
employeeSearchResults.assignAll(
data
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
@ -105,64 +114,77 @@ class AddExpenseController extends GetxController {
}
}
// --- Form Population: Edit Mode ---
// --- Form Population (Edit) ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = '${data['id']}';
selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? '';
amountController.text = '${data['amount'] ?? ''}';
supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
// Transaction Date
if (data['transactionDate'] != null) {
try {
final parsed = DateTime.parse(data['transactionDate']);
selectedTransactionDate.value = parsed;
transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
// Dropdown
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Paid By
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}');
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
// Attachments
existingAttachments.clear();
if (data['attachments'] is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments'])
.map((e) => {...e, 'isActive': true}),
);
}
_setTransactionDate(data['transactionDate']);
_setDropdowns(data);
await _setPaidBy(data);
_setAttachments(data['attachments']);
_logPrefilledData();
}
void _setTransactionDate(dynamic dateStr) {
if (dateStr == null) {
selectedTransactionDate.value = null;
transactionDateController.clear();
return;
}
try {
final parsed = DateTime.parse(dateStr);
selectedTransactionDate.value = parsed;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
void _setDropdowns(Map<String, dynamic> data) {
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
}
Future<void> _setPaidBy(Map<String, dynamic> data) async {
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}',
);
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
}
void _setAttachments(dynamic attachmentsData) {
existingAttachments.clear();
if (attachmentsData is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(attachmentsData).map(
(e) => {...e, 'isActive': true},
),
);
}
}
void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
[
final info = [
'ID: $editingExpenseId',
'Project: ${selectedProject.value}',
'Amount: ${amountController.text}',
@ -177,7 +199,10 @@ class AddExpenseController extends GetxController {
'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}',
].forEach((str) => logSafe(str, level: LogLevel.info));
];
for (var line in info) {
logSafe(line, level: LogLevel.info);
}
}
// --- Pickers ---
@ -199,7 +224,6 @@ class AddExpenseController extends GetxController {
now.minute,
now.second,
);
selectedTransactionDate.value = finalDateTime;
transactionDateController.text =
DateFormat('dd MMM yyyy').format(finalDateTime);
@ -214,8 +238,9 @@ class AddExpenseController extends GetxController {
allowMultiple: true,
);
if (result != null) {
attachments
.addAll(result.paths.whereType<String>().map((path) => File(path)));
attachments.addAll(
result.paths.whereType<String>().map(File.new),
);
}
} catch (e) {
_errorSnackbar("Attachment error: $e");
@ -224,12 +249,20 @@ class AddExpenseController extends GetxController {
void removeAttachment(File file) => attachments.remove(file);
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) attachments.add(File(pickedFile.path));
} catch (e) {
_errorSnackbar("Camera error: $e");
}
}
// --- Location ---
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
final permission = await _ensureLocationPermission();
if (!permission) return;
if (!await _ensureLocationPermission()) return;
final position = await Geolocator.getCurrentPosition();
final placemarks =
@ -241,7 +274,7 @@ class AddExpenseController extends GetxController {
placemarks.first.street,
placemarks.first.locality,
placemarks.first.administrativeArea,
placemarks.first.country
placemarks.first.country,
].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}";
} catch (e) {
@ -271,19 +304,23 @@ class AddExpenseController extends GetxController {
// --- Data Fetching ---
Future<void> loadMasterData() async =>
await Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
if (types is List)
expenseTypes.value =
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
if (types is List) {
expenseTypes.value = types
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
final modes = await ApiService.getMasterPaymentModes();
if (modes is List)
paymentModes.value =
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
if (modes is List) {
paymentModes.value = modes
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
.toList();
}
} catch (_) {
_errorSnackbar("Failed to fetch master data");
}
@ -295,8 +332,8 @@ class AddExpenseController extends GetxController {
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim(),
id = item['id']?.toString().trim();
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null) {
projectsMap[name] = id;
names.add(name);
@ -309,17 +346,6 @@ class AddExpenseController extends GetxController {
}
}
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
attachments.add(File(pickedFile.path));
}
} catch (e) {
_errorSnackbar("Camera error: $e");
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
@ -332,24 +358,7 @@ class AddExpenseController extends GetxController {
}
final payload = await _buildExpensePayload();
final success = isEditMode.value && editingExpenseId != null
? await ApiService.editExpenseApi(
expenseId: editingExpenseId!, payload: payload)
: await ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expensesTypeId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
final success = await _submitToApi(payload);
if (success) {
await expenseController.fetchExpenses();
@ -370,61 +379,71 @@ class AddExpenseController extends GetxController {
}
}
Future<bool> _submitToApi(Map<String, dynamic> payload) async {
if (isEditMode.value && editingExpenseId != null) {
return ApiService.editExpenseApi(
expenseId: editingExpenseId!,
payload: payload,
);
}
return ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expensesTypeId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
}
Future<Map<String, dynamic>> _buildExpensePayload() async {
final now = DateTime.now();
// --- Existing Attachments Payload (for edit mode only) ---
final List<Map<String, dynamic>> existingAttachmentPayloads =
isEditMode.value
? existingAttachments
.map<Map<String, dynamic>>((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": "", // <-- always empty now
})
.toList()
: <Map<String, dynamic>>[];
// --- New Attachments Payload (always include if attachments exist) ---
final List<Map<String, dynamic>> newAttachmentPayloads =
attachments.isNotEmpty
? await Future.wait(attachments.map((file) async {
final bytes = await file.readAsBytes();
final length = await file.length();
return <String, dynamic>{
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType":
lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": length,
final existingPayload = isEditMode.value
? existingAttachments
.map((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
};
}))
: <Map<String, dynamic>>[];
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": "",
})
.toList()
: <Map<String, dynamic>>[];
final newPayload = await Future.wait(
attachments.map((file) async {
final bytes = await file.readAsBytes();
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType":
lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": await file.length(),
"description": "",
};
}),
);
// --- Selected Expense Type ---
final type = selectedExpenseType.value!;
// --- Combine all attachments ---
final List<Map<String, dynamic>> combinedAttachments = [
...existingAttachmentPayloads,
...newAttachmentPayloads
];
// --- Build Payload ---
final payload = <String, dynamic>{
return {
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id,
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
.toIso8601String(),
"transactionDate":
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
"transactionId": transactionIdController.text,
"description": descriptionController.text,
"location": locationController.text,
@ -433,11 +452,13 @@ class AddExpenseController extends GetxController {
"noOfPersons": type.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments":
combinedAttachments.isEmpty ? null : combinedAttachments,
"billAttachments": [
...existingPayload,
...newPayload,
].isEmpty
? null
: [...existingPayload, ...newPayload],
};
return payload;
}
String validateForm() {
@ -450,28 +471,27 @@ class AddExpenseController extends GetxController {
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
// Date Required
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
if (selectedTransactionDate.value != null &&
selectedTransactionDate.value!.isAfter(DateTime.now())) {
if (selectedTransactionDate.value == null) {
missing.add("Transaction Date");
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
final amount = double.tryParse(amountController.text.trim());
if (amount == null) missing.add("Valid Amount");
if (double.tryParse(amountController.text.trim()) == null) {
missing.add("Valid Amount");
}
// Attachment: at least one required at all times
bool hasActiveExisting =
final hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
if (attachments.isEmpty && !hasActiveExisting) {
missing.add("Attachment");
}
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
title: title,
message: msg,
type: SnackbarType.error,
);
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
}

View File

@ -1,11 +1,11 @@
import 'package:intl/intl.dart';
import 'package:marco/helpers/services/app_logger.dart';
// import 'package:marco/helpers/services/app_logger.dart';
class DateTimeUtils {
/// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) {
try {
logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input
// logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input
final parsed = DateTime.parse(utcTimeString);
final utcDateTime = DateTime.utc(
@ -23,13 +23,13 @@ class DateTimeUtils {
final formatted = _formatDateTime(localDateTime, format: format);
logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime
logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string
// logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime
// logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string
return formatted;
} catch (e, stackTrace) {
logSafe('DateTime conversion failed: $e',
error: e, stackTrace: stackTrace);
// logSafe('DateTime conversion failed: $e',
// error: e, stackTrace: stackTrace);
return 'Invalid Date';
}
}
@ -38,10 +38,10 @@ class DateTimeUtils {
static String formatDate(DateTime date, String format) {
try {
final formatted = DateFormat(format).format(date);
logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
// logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
return formatted;
} catch (e, stackTrace) {
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
// logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
return 'Invalid Date';
}
}

View File

@ -0,0 +1,420 @@
// expense_form_widgets.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
/// 🔹 Common Colors & Styles
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
final _tileDecoration = BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
);
/// ==========================
/// Section Title
/// ==========================
class SectionTitle extends StatelessWidget {
final IconData icon;
final String title;
final bool requiredField;
const SectionTitle({
required this.icon,
required this.title,
this.requiredField = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final color = Colors.grey[700];
return Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: title),
if (requiredField)
const TextSpan(
text: ' *',
style: TextStyle(color: Colors.red),
),
],
),
),
],
);
}
}
/// ==========================
/// Custom Text Field
/// ==========================
class CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String hint;
final int maxLines;
final TextInputType keyboardType;
final String? Function(String?)? validator;
const CustomTextField({
required this.controller,
required this.hint,
this.maxLines = 1,
this.keyboardType = TextInputType.text,
this.validator,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
hintText: hint,
hintStyle: _hintStyle,
filled: true,
fillColor: Colors.grey.shade100,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
),
);
}
}
/// ==========================
/// Dropdown Tile
/// ==========================
class DropdownTile extends StatelessWidget {
final String title;
final VoidCallback onTap;
const DropdownTile({
required this.title,
required this.onTap,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: _tileDecoration,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(title,
style: const TextStyle(fontSize: 14, color: Colors.black87),
overflow: TextOverflow.ellipsis),
),
const Icon(Icons.arrow_drop_down),
],
),
),
);
}
}
/// ==========================
/// Tile Container
/// ==========================
class TileContainer extends StatelessWidget {
final Widget child;
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);
}
/// ==========================
/// Attachments Section
/// ==========================
class AttachmentsSection extends StatelessWidget {
final RxList<File> attachments;
final RxList<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew;
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
final VoidCallback onAdd;
const AttachmentsSection({
required this.attachments,
required this.existingAttachments,
required this.onRemoveNew,
this.onRemoveExisting,
required this.onAdd,
Key? key,
}) : super(key: key);
static const allowedImageExtensions = ['jpg', 'jpeg', 'png'];
bool _isImageFile(File file) {
final ext = file.path.split('.').last.toLowerCase();
return allowedImageExtensions.contains(ext);
}
@override
Widget build(BuildContext context) {
return Obx(() {
final activeExisting = existingAttachments
.where((doc) => doc['isActive'] != false)
.toList();
final imageFiles = attachments.where(_isImageFile).toList();
final imageExisting = activeExisting
.where((d) =>
(d['contentType']?.toString().startsWith('image/') ?? false))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (activeExisting.isNotEmpty) ...[
const Text("Existing Attachments",
style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: activeExisting.map((doc) {
final isImage =
doc['contentType']?.toString().startsWith('image/') ??
false;
final url = doc['url'];
final fileName = doc['fileName'] ?? 'Unnamed';
return _buildExistingTile(
context,
doc,
isImage,
url,
fileName,
imageExisting,
);
}).toList(),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...attachments.map((file) => GestureDetector(
onTap: () => _onNewTap(context, file, imageFiles),
child: _AttachmentTile(
file: file,
onRemove: () => onRemoveNew(file),
),
)),
_buildActionTile(Icons.attach_file, onAdd),
_buildActionTile(Icons.camera_alt,
() => Get.find<AddExpenseController>().pickFromCamera()),
],
),
],
);
});
}
/// helper for new file tap
void _onNewTap(BuildContext context, File file, List<File> imageFiles) {
if (_isImageFile(file)) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageFiles,
initialIndex: imageFiles.indexOf(file),
),
);
} else {
showAppSnackbar(
title: 'Info',
message: 'Preview for this file type is not supported.',
type: SnackbarType.info,
);
}
}
/// helper for existing file tile
Widget _buildExistingTile(
BuildContext context,
Map<String, dynamic> doc,
bool isImage,
String? url,
String fileName,
List<Map<String, dynamic>> imageExisting,
) {
return Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () async {
if (isImage) {
final sources = imageExisting.map((e) => e['url']).toList();
final idx = imageExisting.indexOf(doc);
showDialog(
context: context,
builder: (_) =>
ImageViewerDialog(imageSources: sources, initialIndex: idx),
);
} else if (url != null && await canLaunchUrlString(url)) {
await launchUrlString(url, mode: LaunchMode.externalApplication);
} else {
showAppSnackbar(
title: 'Error',
message: 'Could not open the document.',
type: SnackbarType.error,
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isImage ? Icons.image : Icons.insert_drive_file,
size: 20, color: Colors.grey[600]),
const SizedBox(width: 7),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(fileName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
],
),
),
),
if (onRemoveExisting != null)
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: () => onRemoveExisting?.call(doc),
),
),
],
);
}
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: _tileDecoration.copyWith(
border: Border.all(color: Colors.grey.shade400),
),
child: Icon(icon, size: 30, color: Colors.grey),
),
);
}
/// ==========================
/// Attachment Tile
/// ==========================
class _AttachmentTile extends StatelessWidget {
final File file;
final VoidCallback onRemove;
const _AttachmentTile({required this.file, required this.onRemove});
@override
Widget build(BuildContext context) {
final fileName = file.path.split('/').last;
final extension = fileName.split('.').last.toLowerCase();
final isImage = AttachmentsSection.allowedImageExtensions.contains(extension);
final (icon, color) = _fileIcon(extension);
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 80,
height: 80,
decoration: _tileDecoration,
child: isImage
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 30),
const SizedBox(height: 4),
Text(extension.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color)),
],
),
),
Positioned(
top: -6,
right: -6,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.red, size: 18),
onPressed: onRemove,
),
),
],
);
}
/// map extensions to icons/colors
static (IconData, Color) _fileIcon(String ext) {
switch (ext) {
case 'pdf':
return (Icons.picture_as_pdf, Colors.redAccent);
case 'doc':
case 'docx':
return (Icons.description, Colors.blueAccent);
case 'xls':
case 'xlsx':
return (Icons.table_chart, Colors.green);
case 'txt':
return (Icons.article, Colors.grey);
default:
return (Icons.insert_drive_file, Colors.blueGrey);
}
}
}

View File

@ -295,12 +295,15 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
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.phone,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),

File diff suppressed because it is too large Load Diff

View File

@ -11,37 +11,95 @@ 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';
class AttendanceLogsTab extends StatelessWidget {
class AttendanceLogsTab extends StatefulWidget {
final AttendanceController controller;
const AttendanceLogsTab({super.key, required this.controller});
@override
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
}
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
Widget _buildStatusHeader() {
return Obx(() {
final showPending = widget.controller.showPendingOnly.value;
if (!showPending) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.orange.shade50,
child: Row(
children: [
const Icon(
Icons.pending_actions,
color: Colors.orange,
size: 18,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
"Showing Pending Actions Only",
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
),
),
InkWell(
onTap: () {
widget.controller.showPendingOnly.value = false;
},
child: const Icon(
Icons.close,
size: 18,
color: Colors.orange,
),
),
],
),
);
});
}
@override
Widget build(BuildContext context) {
return Obx(() {
final logs = List.of(controller.attendanceLogs);
final logs = List.of(widget.controller.filteredLogs);
logs.sort((a, b) {
final aDate = a.checkIn ?? DateTime(0);
final bDate = b.checkIn ?? DateTime(0);
return bDate.compareTo(aDate);
});
final dateRangeText = controller.startDateAttendance != null &&
controller.endDateAttendance != null
? '${DateTimeUtils.formatDate(controller.startDateAttendance!, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(controller.endDateAttendance!, 'dd MMM yyyy')}'
// Use controller's observable for pending filter
final showPendingOnly = widget.controller.showPendingOnly.value;
final filteredLogs = showPendingOnly
? logs
.where((employee) =>
employee.activity == 1 || employee.activity == 2)
.toList()
: logs;
final dateRangeText = widget.controller.startDateAttendance != null &&
widget.controller.endDateAttendance != null
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - '
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
: 'Select date range';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: Title and Date Range
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleMedium("Attendance Logs", fontWeight: 600),
controller.isLoading.value
widget.controller.isLoading.value
? SkeletonLoaders.dateSkeletonLoader()
: MyText.bodySmall(
dateRangeText,
@ -52,29 +110,37 @@ class AttendanceLogsTab extends StatelessWidget {
],
),
),
if (controller.isLoadingAttendanceLogs.value)
// Pending status header
_buildStatusHeader(),
MySpacing.height(8),
// Content: Skeleton, Empty, or List
if (widget.controller.isLoadingAttendanceLogs.value)
SkeletonLoaders.employeeListSkeletonLoader()
else if (logs.isEmpty)
const SizedBox(
else if (filteredLogs.isEmpty)
SizedBox(
height: 120,
child: Center(
child: Text("No Attendance Logs Found for this Project"),
child: Text(showPendingOnly
? "No Pending Actions Found"
: "No Attendance Logs Found for this Project"),
),
)
else
MyCard.bordered(
paddingAll: 8,
child: Column(
children: List.generate(logs.length, (index) {
final employee = logs[index];
children: List.generate(filteredLogs.length, (index) {
final employee = filteredLogs[index];
final currentDate = employee.checkIn != null
? DateTimeUtils.formatDate(
employee.checkIn!, 'dd MMM yyyy')
: '';
final previousDate =
index > 0 && logs[index - 1].checkIn != null
index > 0 && filteredLogs[index - 1].checkIn != null
? DateTimeUtils.formatDate(
logs[index - 1].checkIn!, 'dd MMM yyyy')
filteredLogs[index - 1].checkIn!, 'dd MMM yyyy')
: '';
final showDateHeader =
index == 0 || currentDate != previousDate;
@ -159,12 +225,12 @@ class AttendanceLogsTab extends StatelessWidget {
children: [
AttendanceActionButton(
employee: employee,
attendanceController: controller,
attendanceController: widget.controller,
),
MySpacing.width(8),
AttendanceLogViewButton(
employee: employee,
attendanceController: controller,
attendanceController: widget.controller,
),
],
),
@ -174,7 +240,7 @@ class AttendanceLogsTab extends StatelessWidget {
],
),
),
if (index != logs.length - 1)
if (index != filteredLogs.length - 1)
Divider(color: Colors.grey.withOpacity(0.3)),
],
);

View File

@ -14,6 +14,7 @@ import 'package:marco/view/Attendence/regularization_requests_tab.dart';
import 'package:marco/view/Attendence/attendance_logs_tab.dart';
import 'package:marco/view/Attendence/todays_attendance_tab.dart';
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
class AttendanceScreen extends StatefulWidget {
const AttendanceScreen({super.key});
@ -113,63 +114,188 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
);
}
Widget _buildFilterAndRefreshRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MyText.bodyMedium("Filter", fontWeight: 600),
Tooltip(
message: 'Filter Project',
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: () async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController,
permissionController: permissionController,
selectedTab: selectedTab,
),
);
if (result != null) {
final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
if (selectedProjectId.isNotEmpty) {
try {
await attendanceController
.fetchEmployeesByProject(selectedProjectId);
await attendanceController
.fetchAttendanceLogs(selectedProjectId);
await attendanceController
.fetchRegularizationLogs(selectedProjectId);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
}
}
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(Icons.tune, size: 18),
Widget _buildFilterSearchRow() {
return Padding(
padding: MySpacing.xy(8, 8),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 35,
child: Obx(() {
final query = attendanceController.searchQuery.value;
return TextField(
controller: TextEditingController(text: query)
..selection = TextSelection.collapsed(offset: query.length),
onChanged: (value) {
attendanceController.searchQuery.value = value;
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
suffixIcon: query.isNotEmpty
? IconButton(
icon: const Icon(Icons.close,
size: 18, color: Colors.grey),
onPressed: () {
attendanceController.searchQuery.value = '';
},
)
: null,
hintText: 'Search by name',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
);
}),
),
),
),
],
MySpacing.width(8),
// 🛠 Filter Icon (no red dot here anymore)
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
icon: const Icon(Icons.tune, size: 20, color: Colors.black87),
onPressed: () async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => AttendanceFilterBottomSheet(
controller: attendanceController,
permissionController: permissionController,
selectedTab: selectedTab,
),
);
if (result != null) {
final selectedProjectId =
projectController.selectedProjectId.value;
final selectedView = result['selectedTab'] as String?;
if (selectedProjectId.isNotEmpty) {
try {
await attendanceController
.fetchEmployeesByProject(selectedProjectId);
await attendanceController
.fetchAttendanceLogs(selectedProjectId);
await attendanceController
.fetchRegularizationLogs(selectedProjectId);
await attendanceController
.fetchProjectData(selectedProjectId);
} catch (_) {}
attendanceController
.update(['attendance_dashboard_controller']);
}
if (selectedView != null && selectedView != selectedTab) {
setState(() => selectedTab = selectedView);
}
}
},
),
),
MySpacing.width(8),
// Pending Actions Menu (red dot here instead)
if (selectedTab == 'attendanceLogs')
Obx(() {
final showPending = attendanceController.showPendingOnly.value;
return Stack(
children: [
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
),
child: PopupMenuButton<int>(
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_vert,
size: 20, color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
itemBuilder: (context) => [
const PopupMenuItem<int>(
enabled: false,
height: 30,
child: Text(
"Preferences",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
PopupMenuItem<int>(
value: 0,
enabled: false,
child: Obx(() => Row(
children: [
const SizedBox(width: 10),
const Expanded(
child: Text('Show Pending Actions')),
Switch.adaptive(
value: attendanceController
.showPendingOnly.value,
activeColor: Colors.indigo,
onChanged: (val) {
attendanceController
.showPendingOnly.value = val;
Navigator.pop(context);
},
),
],
)),
),
],
),
),
if (showPending)
Positioned(
top: 6,
right: 6,
child: Container(
height: 8,
width: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
);
}),
],
),
);
}
@ -222,8 +348,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(flexSpacing),
_buildFilterAndRefreshRow(),
MySpacing.height(flexSpacing),
_buildFilterSearchRow(),
MyFlex(
children: [
MyFlexItem(

View File

@ -27,7 +27,7 @@ class RegularizationRequestsTab extends StatelessWidget {
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
),
Obx(() {
final employees = controller.regularizationLogs;
final employees = controller.filteredRegularizationLogs;
if (controller.isLoadingRegularizationLogs.value) {
return SkeletonLoaders.employeeListSkeletonLoader();
@ -37,7 +37,8 @@ class RegularizationRequestsTab extends StatelessWidget {
return const SizedBox(
height: 120,
child: Center(
child: Text("No Regularization Requests Found for this Project"),
child:
Text("No Regularization Requests Found for this Project"),
),
);
}

View File

@ -20,7 +20,7 @@ class TodaysAttendanceTab extends StatelessWidget {
Widget build(BuildContext context) {
return Obx(() {
final isLoading = controller.isLoadingEmployees.value;
final employees = controller.employees;
final employees = controller.filteredEmployees;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -30,7 +30,8 @@ class TodaysAttendanceTab extends StatelessWidget {
child: Row(
children: [
Expanded(
child: MyText.titleMedium("Today's Attendance", fontWeight: 600),
child:
MyText.titleMedium("Today's Attendance", fontWeight: 600),
),
MyText.bodySmall(
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
@ -43,7 +44,9 @@ class TodaysAttendanceTab extends StatelessWidget {
if (isLoading)
SkeletonLoaders.employeeListSkeletonLoader()
else if (employees.isEmpty)
const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned")))
const SizedBox(
height: 120,
child: Center(child: Text("No Employees Assigned")))
else
MyCard.bordered(
paddingAll: 8,
@ -57,7 +60,10 @@ class TodaysAttendanceTab extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31),
Avatar(
firstName: employee.firstName,
lastName: employee.lastName,
size: 31),
MySpacing.width(16),
Expanded(
child: Column(
@ -66,27 +72,39 @@ class TodaysAttendanceTab extends StatelessWidget {
Wrap(
spacing: 6,
children: [
MyText.bodyMedium(employee.name, fontWeight: 600),
MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]),
MyText.bodyMedium(employee.name,
fontWeight: 600),
MyText.bodySmall(
'(${employee.designation})',
fontWeight: 600,
color: Colors.grey[700]),
],
),
MySpacing.height(8),
if (employee.checkIn != null || employee.checkOut != null)
if (employee.checkIn != null ||
employee.checkOut != null)
Row(
children: [
if (employee.checkIn != null)
Row(
children: [
const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green),
const Icon(
Icons.arrow_circle_right,
size: 16,
color: Colors.green),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(employee.checkIn!, 'hh:mm a')),
Text(DateTimeUtils.formatDate(
employee.checkIn!,
'hh:mm a')),
],
),
if (employee.checkOut != null) ...[
MySpacing.width(16),
const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red),
const Icon(Icons.arrow_circle_left,
size: 16, color: Colors.red),
MySpacing.width(4),
Text(DateTimeUtils.formatDate(employee.checkOut!, 'hh:mm a')),
Text(DateTimeUtils.formatDate(
employee.checkOut!, 'hh:mm a')),
],
],
),

View File

@ -18,7 +18,8 @@ class DailyTaskPlanningScreen extends StatefulWidget {
DailyTaskPlanningScreen({super.key});
@override
State<DailyTaskPlanningScreen> createState() => _DailyTaskPlanningScreenState();
State<DailyTaskPlanningScreen> createState() =>
_DailyTaskPlanningScreenState();
}
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
@ -270,12 +271,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
"${buildingKey}_${floor.floorName}_${area.areaName}";
final isExpanded =
floorExpansionState[floorWorkAreaKey] ?? false;
final totalPlanned = area.workItems
.map((wi) => wi.workItem.plannedWork ?? 0)
.fold<double>(0, (prev, curr) => prev + curr);
final totalCompleted = area.workItems
.map((wi) => wi.workItem.completedWork ?? 0)
.fold<double>(0, (prev, curr) => prev + curr);
final workItems = area.workItems;
final totalPlanned = workItems.fold<double>(
0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0));
final totalCompleted = workItems.fold<double>(0,
(sum, wi) => sum + (wi.workItem.completedWork ?? 0));
final totalProgress = totalPlanned == 0
? 0.0
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);